feat(back-end and front-end) cad bill with order
This commit is contained in:
@@ -28,6 +28,13 @@ const appChildRoutes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'checkout/cad',
|
||||
loadComponent: () =>
|
||||
import('./features/checkout/checkout.component').then(
|
||||
(m) => m.CheckoutComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'checkout',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
</select>
|
||||
|
||||
<div class="icon-placeholder">
|
||||
<div class="icon-placeholder" routerLink="/admin">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { LanguageService } from '../services/language.service';
|
||||
import {routes} from '../../app.routes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navbar',
|
||||
@@ -37,4 +38,6 @@ export class NavbarComponent {
|
||||
closeMenu() {
|
||||
this.isMenuOpen = false;
|
||||
}
|
||||
|
||||
protected readonly routes = routes;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@ export const ADMIN_ROUTES: Routes = [
|
||||
(m) => m.AdminSessionsComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'cad-invoices',
|
||||
loadComponent: () =>
|
||||
import('./pages/admin-cad-invoices.component').then(
|
||||
(m) => m.AdminCadInvoicesComponent,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<section class="cad-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Fatture CAD</h1>
|
||||
<p>
|
||||
Crea un checkout CAD partendo da una sessione esistente (opzionale) e
|
||||
gestisci lo stato fino all'ordine.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" (click)="loadCadInvoices()" [disabled]="loading">
|
||||
Aggiorna
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
|
||||
|
||||
<section class="create-box">
|
||||
<h2>Crea nuova fattura CAD</h2>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>ID Sessione (opzionale)</span>
|
||||
<input
|
||||
[(ngModel)]="form.sessionId"
|
||||
placeholder="UUID sessione quote"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>ID Richiesta Contatto (opzionale)</span>
|
||||
<input
|
||||
[(ngModel)]="form.sourceRequestId"
|
||||
placeholder="UUID richiesta contatto"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Ore CAD</span>
|
||||
<input [(ngModel)]="form.cadHours" min="0.1" step="0.1" type="number" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Tariffa CAD CHF/h (opzionale)</span>
|
||||
<input
|
||||
[(ngModel)]="form.cadHourlyRateChf"
|
||||
placeholder="Se vuoto usa pricing policy attiva"
|
||||
min="0"
|
||||
step="0.05"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<label class="notes-field">
|
||||
<span>Nota (opzionale)</span>
|
||||
<textarea
|
||||
[(ngModel)]="form.notes"
|
||||
placeholder="Nota visibile nel checkout CAD (es. dettagli lavorazione)"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="create-actions">
|
||||
<button type="button" (click)="createCadInvoice()" [disabled]="creating">
|
||||
{{ creating ? "Creazione..." : "Crea link checkout CAD" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-wrap" *ngIf="!loading; else loadingTpl">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sessione</th>
|
||||
<th>Richiesta</th>
|
||||
<th>Ore CAD</th>
|
||||
<th>Tariffa</th>
|
||||
<th>Totale CAD</th>
|
||||
<th>Totale ordine</th>
|
||||
<th>Stato sessione</th>
|
||||
<th>Nota</th>
|
||||
<th>Ordine</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let row of invoices">
|
||||
<td [title]="row.sessionId">{{ row.sessionId | slice: 0 : 8 }}</td>
|
||||
<td>{{ row.sourceRequestId || "-" }}</td>
|
||||
<td>{{ row.cadHours }}</td>
|
||||
<td>{{ row.cadHourlyRateChf | currency: "CHF" }}</td>
|
||||
<td>{{ row.cadTotalChf | currency: "CHF" }}</td>
|
||||
<td>{{ row.grandTotalChf | currency: "CHF" }}</td>
|
||||
<td>{{ row.sessionStatus }}</td>
|
||||
<td class="notes-cell" [title]="row.notes || ''">{{ row.notes || "-" }}</td>
|
||||
<td>
|
||||
<span *ngIf="row.convertedOrderId; else noOrder">
|
||||
{{ row.convertedOrderId | slice: 0 : 8 }} ({{
|
||||
row.convertedOrderStatus || "-"
|
||||
}})
|
||||
</span>
|
||||
<ng-template #noOrder>-</ng-template>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="ghost" (click)="openCheckout(row.checkoutPath)">
|
||||
Apri checkout
|
||||
</button>
|
||||
<button type="button" class="ghost" (click)="copyCheckout(row.checkoutPath)">
|
||||
Copia link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost"
|
||||
*ngIf="row.convertedOrderId"
|
||||
(click)="downloadInvoice(row.convertedOrderId)"
|
||||
>
|
||||
Scarica fattura
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="invoices.length === 0">
|
||||
<td colspan="10">Nessuna fattura CAD trovata.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<ng-template #loadingTpl>
|
||||
<p>Caricamento fatture CAD...</p>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,140 @@
|
||||
.cad-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-brand);
|
||||
color: var(--color-neutral-900);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.create-box {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.create-box h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.form-grid span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-grid input {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.notes-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-grid textarea {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.create-actions {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1100px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.notes-cell {
|
||||
max-width: 280px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger-500);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--color-success-500);
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
AdminCadInvoice,
|
||||
AdminOperationsService,
|
||||
} from '../services/admin-operations.service';
|
||||
import { AdminOrdersService } from '../services/admin-orders.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-cad-invoices',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-cad-invoices.component.html',
|
||||
styleUrl: './admin-cad-invoices.component.scss',
|
||||
})
|
||||
export class AdminCadInvoicesComponent implements OnInit {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
private readonly adminOrdersService = inject(AdminOrdersService);
|
||||
|
||||
invoices: AdminCadInvoice[] = [];
|
||||
loading = false;
|
||||
creating = false;
|
||||
errorMessage: string | null = null;
|
||||
successMessage: string | null = null;
|
||||
|
||||
form = {
|
||||
sessionId: '',
|
||||
sourceRequestId: '',
|
||||
cadHours: 1,
|
||||
cadHourlyRateChf: '',
|
||||
notes: '',
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCadInvoices();
|
||||
}
|
||||
|
||||
loadCadInvoices(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = null;
|
||||
this.adminOperationsService.listCadInvoices().subscribe({
|
||||
next: (rows) => {
|
||||
this.invoices = rows;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Impossibile caricare le fatture CAD.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createCadInvoice(): void {
|
||||
if (this.creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cadHours = Number(this.form.cadHours);
|
||||
if (!Number.isFinite(cadHours) || cadHours <= 0) {
|
||||
this.errorMessage = 'Inserisci ore CAD valide (> 0).';
|
||||
return;
|
||||
}
|
||||
|
||||
this.creating = true;
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
|
||||
let payload: {
|
||||
sessionId?: string;
|
||||
sourceRequestId?: string;
|
||||
cadHours: number;
|
||||
cadHourlyRateChf?: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const sessionIdRaw = String(this.form.sessionId ?? '').trim();
|
||||
const sourceRequestIdRaw = String(this.form.sourceRequestId ?? '').trim();
|
||||
const cadRateRaw = String(this.form.cadHourlyRateChf ?? '').trim();
|
||||
const notesRaw = String(this.form.notes ?? '').trim();
|
||||
|
||||
payload = {
|
||||
sessionId: sessionIdRaw || undefined,
|
||||
sourceRequestId: sourceRequestIdRaw || undefined,
|
||||
cadHours,
|
||||
cadHourlyRateChf:
|
||||
cadRateRaw.length > 0 && Number.isFinite(Number(cadRateRaw))
|
||||
? Number(cadRateRaw)
|
||||
: undefined,
|
||||
notes: notesRaw.length > 0 ? notesRaw : undefined,
|
||||
};
|
||||
} catch {
|
||||
this.creating = false;
|
||||
this.errorMessage = 'Valori form non validi.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOperationsService.createCadInvoice(payload).subscribe({
|
||||
next: (created) => {
|
||||
this.creating = false;
|
||||
this.successMessage = `Fattura CAD pronta. Sessione: ${created.sessionId}`;
|
||||
this.loadCadInvoices();
|
||||
},
|
||||
error: (err) => {
|
||||
this.creating = false;
|
||||
this.errorMessage =
|
||||
err?.error?.message || 'Creazione fattura CAD non riuscita.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openCheckout(path: string): void {
|
||||
const url = this.toCheckoutUrl(path);
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
copyCheckout(path: string): void {
|
||||
const url = this.toCheckoutUrl(path);
|
||||
navigator.clipboard?.writeText(url);
|
||||
this.successMessage = 'Link checkout CAD copiato negli appunti.';
|
||||
}
|
||||
|
||||
downloadInvoice(orderId?: string): void {
|
||||
if (!orderId) return;
|
||||
this.adminOrdersService.downloadOrderInvoice(orderId).subscribe({
|
||||
next: (blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `fattura-cad-${orderId}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Download fattura non riuscito.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private toCheckoutUrl(path: string): string {
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`;
|
||||
const lang = this.resolveLang();
|
||||
return `${window.location.origin}/${lang}${safePath}`;
|
||||
}
|
||||
|
||||
private resolveLang(): string {
|
||||
const firstSegment = window.location.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.shift();
|
||||
if (firstSegment && ['it', 'en', 'de', 'fr'].includes(firstSegment)) {
|
||||
return firstSegment;
|
||||
}
|
||||
return 'it';
|
||||
}
|
||||
}
|
||||
@@ -199,18 +199,20 @@ tbody tr.selected {
|
||||
.request-id {
|
||||
margin: var(--space-2) 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.request-id code {
|
||||
display: inline-block;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text);
|
||||
background: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -47,6 +47,13 @@
|
||||
>
|
||||
{{ isDetailOpen(session.id) ? "Nascondi" : "Vedi" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
(click)="copySessionUuid(session.id)"
|
||||
>
|
||||
Copia UUID
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
@@ -78,6 +85,11 @@
|
||||
"
|
||||
class="detail-box"
|
||||
>
|
||||
<div class="detail-session-id">
|
||||
<strong>UUID sessione:</strong>
|
||||
<code>{{ detail.session.id }}</code>
|
||||
</div>
|
||||
|
||||
<div class="detail-summary">
|
||||
<div>
|
||||
<strong>Elementi:</strong> {{ detail.items.length }}
|
||||
|
||||
@@ -103,6 +103,22 @@ td {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-session-id {
|
||||
margin-bottom: var(--space-3);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-session-id code {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-neutral-100);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -135,6 +135,42 @@ export class AdminSessionsComponent implements OnInit {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
copySessionUuid(sessionId: string): void {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(sessionId).then(
|
||||
() => {
|
||||
this.errorMessage = null;
|
||||
this.successMessage = 'UUID sessione copiato.';
|
||||
},
|
||||
() => {
|
||||
this.errorMessage = 'Impossibile copiare UUID sessione.';
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for older browsers.
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = sessionId;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
this.errorMessage = null;
|
||||
this.successMessage = 'UUID sessione copiato.';
|
||||
} catch {
|
||||
this.errorMessage = 'Impossibile copiare UUID sessione.';
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
private extractErrorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as { error?: { message?: string } };
|
||||
return err?.error?.message || fallback;
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
>Richieste contatto</a
|
||||
>
|
||||
<a routerLink="sessions" routerLinkActive="active">Sessioni</a>
|
||||
<a routerLink="cad-invoices" routerLinkActive="active"
|
||||
>Fatture CAD</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -115,6 +115,10 @@ export interface AdminQuoteSession {
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
convertedOrderId?: string;
|
||||
sourceRequestId?: string;
|
||||
cadHours?: number;
|
||||
cadHourlyRateChf?: number;
|
||||
cadTotalChf?: number;
|
||||
}
|
||||
|
||||
export interface AdminQuoteSessionDetailItem {
|
||||
@@ -136,14 +140,45 @@ export interface AdminQuoteSessionDetail {
|
||||
setupCostChf?: number;
|
||||
supportsEnabled?: boolean;
|
||||
notes?: string;
|
||||
sourceRequestId?: string;
|
||||
cadHours?: number;
|
||||
cadHourlyRateChf?: number;
|
||||
};
|
||||
items: AdminQuoteSessionDetailItem[];
|
||||
printItemsTotalChf: number;
|
||||
cadTotalChf: number;
|
||||
itemsTotalChf: number;
|
||||
shippingCostChf: number;
|
||||
globalMachineCostChf: number;
|
||||
grandTotalChf: number;
|
||||
}
|
||||
|
||||
export interface AdminCreateCadInvoicePayload {
|
||||
sessionId?: string;
|
||||
sourceRequestId?: string;
|
||||
cadHours: number;
|
||||
cadHourlyRateChf?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AdminCadInvoice {
|
||||
sessionId: string;
|
||||
sessionStatus: string;
|
||||
sourceRequestId?: string;
|
||||
cadHours: number;
|
||||
cadHourlyRateChf: number;
|
||||
cadTotalChf: number;
|
||||
printItemsTotalChf: number;
|
||||
setupCostChf: number;
|
||||
shippingCostChf: number;
|
||||
grandTotalChf: number;
|
||||
convertedOrderId?: string;
|
||||
convertedOrderStatus?: string;
|
||||
checkoutPath: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -279,4 +314,20 @@ export class AdminOperationsService {
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
|
||||
listCadInvoices(): Observable<AdminCadInvoice[]> {
|
||||
return this.http.get<AdminCadInvoice[]>(`${this.baseUrl}/cad-invoices`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
createCadInvoice(
|
||||
payload: AdminCreateCadInvoicePayload,
|
||||
): Observable<AdminCadInvoice> {
|
||||
return this.http.post<AdminCadInvoice>(
|
||||
`${this.baseUrl}/cad-invoices`,
|
||||
payload,
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ export interface AdminOrder {
|
||||
customerEmail: string;
|
||||
totalChf: number;
|
||||
createdAt: string;
|
||||
isCadOrder?: boolean;
|
||||
sourceRequestId?: string;
|
||||
cadHours?: number;
|
||||
cadHourlyRateChf?: number;
|
||||
cadTotalChf?: number;
|
||||
printMaterialCode?: string;
|
||||
printNozzleDiameterMm?: number;
|
||||
printLayerHeightMm?: number;
|
||||
|
||||
@@ -23,14 +23,16 @@
|
||||
<div
|
||||
class="mode-option"
|
||||
[class.active]="mode() === 'easy'"
|
||||
(click)="mode.set('easy')"
|
||||
[class.disabled]="cadSessionLocked()"
|
||||
(click)="!cadSessionLocked() && mode.set('easy')"
|
||||
>
|
||||
{{ "CALC.MODE_EASY" | translate }}
|
||||
</div>
|
||||
<div
|
||||
class="mode-option"
|
||||
[class.active]="mode() === 'advanced'"
|
||||
(click)="mode.set('advanced')"
|
||||
[class.disabled]="cadSessionLocked()"
|
||||
(click)="!cadSessionLocked() && mode.set('advanced')"
|
||||
>
|
||||
{{ "CALC.MODE_ADVANCED" | translate }}
|
||||
</div>
|
||||
@@ -39,6 +41,7 @@
|
||||
<app-upload-form
|
||||
#uploadForm
|
||||
[mode]="mode()"
|
||||
[lockedSettings]="cadSessionLocked()"
|
||||
[loading]="loading()"
|
||||
[uploadProgress]="uploadProgress()"
|
||||
(submitRequest)="onCalculate($event)"
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.benefits {
|
||||
|
||||
@@ -48,6 +48,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
loading = signal(false);
|
||||
uploadProgress = signal(0);
|
||||
result = signal<QuoteResult | null>(null);
|
||||
cadSessionLocked = signal(false);
|
||||
error = signal<boolean>(false);
|
||||
errorKey = signal<string>('CALC.ERROR_GENERIC');
|
||||
isZeroQuoteError = computed(
|
||||
@@ -100,6 +101,8 @@ export class CalculatorPageComponent implements OnInit {
|
||||
this.error.set(false);
|
||||
this.errorKey.set('CALC.ERROR_GENERIC');
|
||||
this.result.set(result);
|
||||
const isCadSession = data?.session?.status === 'CAD_ACTIVE';
|
||||
this.cadSessionLocked.set(isCadSession);
|
||||
this.step.set('quote');
|
||||
|
||||
// 2. Determine Mode (Heuristic)
|
||||
@@ -206,6 +209,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
this.error.set(false);
|
||||
this.errorKey.set('CALC.ERROR_GENERIC');
|
||||
this.result.set(null);
|
||||
this.cadSessionLocked.set(false);
|
||||
this.orderSuccess.set(false);
|
||||
|
||||
// Auto-scroll on mobile to make analysis visible
|
||||
@@ -270,10 +274,10 @@ export class CalculatorPageComponent implements OnInit {
|
||||
onProceed() {
|
||||
const res = this.result();
|
||||
if (res && res.sessionId) {
|
||||
this.router.navigate(
|
||||
['/', this.languageService.selectedLang(), 'checkout'],
|
||||
{ queryParams: { session: res.sessionId } },
|
||||
);
|
||||
const segments = this.cadSessionLocked()
|
||||
? ['/', this.languageService.selectedLang(), 'checkout', 'cad']
|
||||
: ['/', this.languageService.selectedLang(), 'checkout'];
|
||||
this.router.navigate(segments, { queryParams: { session: res.sessionId } });
|
||||
} else {
|
||||
console.error('No session ID found in quote result');
|
||||
// Fallback or error handling
|
||||
@@ -343,6 +347,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
onNewQuote() {
|
||||
this.step.set('upload');
|
||||
this.result.set(null);
|
||||
this.cadSessionLocked.set(false);
|
||||
this.orderSuccess.set(false);
|
||||
this.mode.set('easy'); // Reset to default
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
: { cost: (result().setupCost | currency: result().currency) }
|
||||
}}</small
|
||||
><br />
|
||||
@if ((result().cadTotal || 0) > 0) {
|
||||
<small class="shipping-note" style="color: #666">
|
||||
Servizio CAD: {{ result().cadTotal | currency: result().currency }}
|
||||
</small>
|
||||
<br />
|
||||
}
|
||||
<small class="shipping-note" style="color: #666">{{
|
||||
"CALC.SHIPPING_NOTE" | translate
|
||||
}}</small>
|
||||
|
||||
@@ -120,8 +120,9 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
totals = computed(() => {
|
||||
const currentItems = this.items();
|
||||
const setup = this.result().setupCost;
|
||||
const cad = this.result().cadTotal || 0;
|
||||
|
||||
let price = setup;
|
||||
let price = setup + cad;
|
||||
let time = 0;
|
||||
let weight = 0;
|
||||
|
||||
|
||||
@@ -118,6 +118,12 @@
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
@if (lockedSettings()) {
|
||||
<p class="upload-privacy-note">
|
||||
Parametri stampa bloccati per sessione CAD: materiale, nozzle, layer,
|
||||
infill e supporti sono definiti dal back-office.
|
||||
</p>
|
||||
}
|
||||
<app-select
|
||||
formControlName="material"
|
||||
[label]="'CALC.MATERIAL' | translate"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
signal,
|
||||
OnInit,
|
||||
inject,
|
||||
effect,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
@@ -57,6 +58,7 @@ interface FormItem {
|
||||
})
|
||||
export class UploadFormComponent implements OnInit {
|
||||
mode = input<'easy' | 'advanced'>('easy');
|
||||
lockedSettings = input<boolean>(false);
|
||||
loading = input<boolean>(false);
|
||||
uploadProgress = input<number>(0);
|
||||
submitRequest = output<QuoteRequest>();
|
||||
@@ -139,6 +141,10 @@ export class UploadFormComponent implements OnInit {
|
||||
if (this.mode() !== 'easy' || this.isPatchingSettings) return;
|
||||
this.applyAdvancedPresetFromQuality(quality);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.applySettingsLock(this.lockedSettings());
|
||||
});
|
||||
}
|
||||
|
||||
private applyAdvancedPresetFromQuality(quality: string | null | undefined) {
|
||||
@@ -520,7 +526,7 @@ export class UploadFormComponent implements OnInit {
|
||||
this.form.value,
|
||||
);
|
||||
this.submitRequest.emit({
|
||||
...this.form.value,
|
||||
...this.form.getRawValue(),
|
||||
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
|
||||
mode: this.mode(),
|
||||
});
|
||||
@@ -554,4 +560,26 @@ export class UploadFormComponent implements OnInit {
|
||||
private normalizeFileName(fileName: string): string {
|
||||
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
private applySettingsLock(locked: boolean): void {
|
||||
const controlsToLock = [
|
||||
'material',
|
||||
'quality',
|
||||
'nozzleDiameter',
|
||||
'infillPattern',
|
||||
'layerHeight',
|
||||
'infillDensity',
|
||||
'supportEnabled',
|
||||
];
|
||||
|
||||
controlsToLock.forEach((name) => {
|
||||
const control = this.form.get(name);
|
||||
if (!control) return;
|
||||
if (locked) {
|
||||
control.disable({ emitEvent: false });
|
||||
} else {
|
||||
control.enable({ emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface QuoteResult {
|
||||
items: QuoteItem[];
|
||||
setupCost: number;
|
||||
globalMachineCost: number;
|
||||
cadHours?: number;
|
||||
cadTotal?: number;
|
||||
currency: string;
|
||||
totalPrice: number;
|
||||
totalTimeHours: number;
|
||||
@@ -463,6 +465,8 @@ export class QuoteEstimatorService {
|
||||
})),
|
||||
setupCost: session.setupCostChf || 0,
|
||||
globalMachineCost: sessionData.globalMachineCostChf || 0,
|
||||
cadHours: session.cadHours || 0,
|
||||
cadTotal: sessionData.cadTotalChf || 0,
|
||||
currency: 'CHF', // Fixed for now
|
||||
totalPrice:
|
||||
(sessionData.itemsTotalChf || 0) +
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<div class="checkout-page">
|
||||
<div class="container hero">
|
||||
<h1 class="section-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
|
||||
<p class="cad-subtitle" *ngIf="isCadSession()">
|
||||
Servizio CAD
|
||||
<ng-container *ngIf="cadRequestId()">
|
||||
riferito alla richiesta contatto #{{ cadRequestId() }}
|
||||
</ng-container>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
@@ -260,6 +266,17 @@
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
|
||||
<div class="item-details">
|
||||
<span class="item-name">Servizio CAD</span>
|
||||
<div class="item-specs-sub">{{ cadHours() }}h</div>
|
||||
</div>
|
||||
<div class="item-price">
|
||||
<span class="item-total-price">
|
||||
{{ cadTotal() | currency: "CHF" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-totals" *ngIf="quoteSession() as session">
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cad-subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.checkout-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 420px;
|
||||
@@ -260,6 +265,13 @@ app-toggle-selector.user-type-selector-compact {
|
||||
}
|
||||
}
|
||||
|
||||
.cad-summary-item {
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: var(--radius-sm);
|
||||
padding-left: var(--space-3);
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
.summary-totals {
|
||||
background: var(--color-neutral-100);
|
||||
padding: var(--space-4);
|
||||
|
||||
@@ -162,6 +162,22 @@ export class CheckoutComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
isCadSession(): boolean {
|
||||
return this.quoteSession()?.session?.status === 'CAD_ACTIVE';
|
||||
}
|
||||
|
||||
cadRequestId(): string | null {
|
||||
return this.quoteSession()?.session?.sourceRequestId ?? null;
|
||||
}
|
||||
|
||||
cadHours(): number {
|
||||
return this.quoteSession()?.session?.cadHours ?? 0;
|
||||
}
|
||||
|
||||
cadTotal(): number {
|
||||
return this.quoteSession()?.cadTotalChf ?? 0;
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (this.checkoutForm.invalid) {
|
||||
return;
|
||||
|
||||
@@ -198,6 +198,10 @@
|
||||
<span>{{ "PAYMENT.SUBTOTAL" | translate }}</span>
|
||||
<span>{{ o.subtotalChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
<div class="total-row" *ngIf="o.cadTotalChf > 0">
|
||||
<span>Servizio CAD ({{ o.cadHours || 0 }}h)</span>
|
||||
<span>{{ o.cadTotalChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ "PAYMENT.SHIPPING" | translate }}</span>
|
||||
<span>{{ o.shippingCostChf | currency: "CHF" }}</span>
|
||||
|
||||
Reference in New Issue
Block a user