feat(back-end and front-end): back-office pazzo
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 44s
Build, Test and Deploy / build-and-push (push) Successful in 46s
Build, Test and Deploy / deploy (push) Successful in 9s

This commit is contained in:
2026-02-27 15:46:41 +01:00
parent 47553ebb82
commit ed76b13e4c
30 changed files with 2616 additions and 272 deletions

View File

@@ -20,10 +20,6 @@ export const ADMIN_ROUTES: Routes = [
path: 'orders',
loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent)
},
{
path: 'orders-past',
loadComponent: () => import('./pages/admin-orders-past.component').then(m => m.AdminOrdersPastComponent)
},
{
path: 'filament-stock',
loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent)

View File

@@ -1,40 +1,119 @@
<section class="section-card">
<header class="section-header">
<div>
<div class="header-copy">
<h2>Richieste di contatto</h2>
<p>Richieste preventivo personalizzato ricevute dal sito.</p>
<span class="total-pill">{{ requests.length }} richieste</span>
</div>
<button type="button" (click)="loadRequests()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Data</th>
<th>Nome / Azienda</th>
<th>Email</th>
<th>Tipo richiesta</th>
<th>Tipo cliente</th>
<th>Stato</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let request of requests">
<td>{{ request.createdAt | date:'short' }}</td>
<td>{{ request.name || request.companyName || '-' }}</td>
<td>{{ request.email }}</td>
<td>{{ request.requestType }}</td>
<td>{{ request.customerType }}</td>
<td>{{ request.status }}</td>
</tr>
</tbody>
</table>
<div class="workspace" *ngIf="!loading; else loadingTpl">
<section class="list-panel">
<h3>Lista richieste</h3>
<div class="table-wrap">
<table class="requests-table">
<thead>
<tr>
<th>Data</th>
<th>Nome / Azienda</th>
<th>Email</th>
<th>Tipo richiesta</th>
<th>Tipo cliente</th>
<th>Stato</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let request of requests"
[class.selected]="isSelected(request.id)"
(click)="openDetails(request.id)"
>
<td class="created-at">{{ request.createdAt | date:'short' }}</td>
<td class="name-cell">
<p class="primary">{{ request.name || request.companyName || '-' }}</p>
<p class="secondary" *ngIf="request.name && request.companyName">{{ request.companyName }}</p>
</td>
<td class="email-cell">{{ request.email }}</td>
<td>
<span class="chip chip-neutral">{{ request.requestType }}</span>
</td>
<td>
<span class="chip chip-light">{{ request.customerType }}</span>
</td>
<td>
<span class="chip" [ngClass]="getStatusChipClass(request.status)">{{ request.status }}</span>
</td>
</tr>
<tr class="empty-row" *ngIf="requests.length === 0">
<td colspan="6">Nessuna richiesta presente.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="detail-panel" *ngIf="selectedRequest">
<header class="detail-header">
<div>
<h3>Dettaglio richiesta</h3>
<p class="request-id"><span>ID</span><code>{{ selectedRequest.id }}</code></p>
</div>
<div class="detail-chips">
<span class="chip" [ngClass]="getStatusChipClass(selectedRequest.status)">{{ selectedRequest.status }}</span>
<span class="chip chip-neutral">{{ selectedRequest.requestType }}</span>
<span class="chip chip-light">{{ selectedRequest.customerType }}</span>
</div>
</header>
<p class="loading-detail" *ngIf="detailLoading">Caricamento dettaglio...</p>
<dl class="meta-grid">
<div class="meta-item"><dt>Creata</dt><dd>{{ selectedRequest.createdAt | date:'medium' }}</dd></div>
<div class="meta-item"><dt>Aggiornata</dt><dd>{{ selectedRequest.updatedAt | date:'medium' }}</dd></div>
<div class="meta-item"><dt>Email</dt><dd>{{ selectedRequest.email }}</dd></div>
<div class="meta-item"><dt>Telefono</dt><dd>{{ selectedRequest.phone || '-' }}</dd></div>
<div class="meta-item"><dt>Nome</dt><dd>{{ selectedRequest.name || '-' }}</dd></div>
<div class="meta-item"><dt>Azienda</dt><dd>{{ selectedRequest.companyName || '-' }}</dd></div>
<div class="meta-item"><dt>Referente</dt><dd>{{ selectedRequest.contactPerson || '-' }}</dd></div>
</dl>
<div class="message-box">
<h4>Messaggio</h4>
<p>{{ selectedRequest.message || '-' }}</p>
</div>
<div class="attachments">
<h4>Allegati</h4>
<div class="attachment-list" *ngIf="selectedRequest.attachments.length > 0; else noAttachmentsTpl">
<article class="attachment-item" *ngFor="let attachment of selectedRequest.attachments">
<div>
<p class="filename">{{ attachment.originalFilename }}</p>
<p class="meta">
{{ formatFileSize(attachment.fileSizeBytes) }}
<span *ngIf="attachment.mimeType"> | {{ attachment.mimeType }}</span>
<span *ngIf="attachment.createdAt"> | {{ attachment.createdAt | date:'short' }}</span>
</p>
</div>
<button type="button" class="ghost" (click)="downloadAttachment(attachment)">Scarica file</button>
</article>
</div>
</div>
</section>
<section class="detail-panel empty" *ngIf="!selectedRequest">
<h3>Nessuna richiesta selezionata</h3>
<p>Seleziona una riga dalla lista per vedere il dettaglio.</p>
</section>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento richieste...</p>
</ng-template>
<ng-template #noAttachmentsTpl>
<p class="muted">Nessun allegato disponibile.</p>
</ng-template>

View File

@@ -11,18 +11,44 @@
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-5);
margin-bottom: var(--space-4);
}
h2 {
.section-header h2 {
margin: 0;
font-size: 1.4rem;
}
p {
.section-header p {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
.header-copy {
display: grid;
gap: var(--space-1);
}
.total-pill {
width: fit-content;
margin-top: var(--space-1);
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-neutral-100);
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
padding: 6px 10px;
}
.workspace {
display: grid;
grid-template-columns: minmax(500px, 1.25fr) minmax(420px, 1fr);
gap: var(--space-4);
align-items: start;
}
button {
border: 0;
border-radius: var(--radius-md);
@@ -31,15 +57,31 @@ button {
padding: var(--space-2) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
transition: background-color 0.2s ease, opacity 0.2s ease;
line-height: 1.2;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
button.ghost {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.list-panel h3 {
margin: 0 0 var(--space-2);
font-size: 1.02rem;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
max-height: 72vh;
}
table {
@@ -47,13 +89,300 @@ table {
border-collapse: collapse;
}
thead {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-neutral-100);
}
th,
td {
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
font-size: 0.92rem;
vertical-align: top;
}
th {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted);
}
.name-cell .primary {
margin: 0;
font-weight: 600;
}
.name-cell .secondary {
margin: 2px 0 0;
font-size: 0.82rem;
color: var(--color-text-muted);
}
.email-cell,
.created-at {
color: var(--color-text-muted);
}
tbody tr {
cursor: pointer;
transition: background-color 0.15s ease;
}
tbody tr:hover {
background: #fff9d9;
}
tbody tr.selected {
background: #fff5b8;
}
.empty-row {
cursor: default;
}
.empty-row:hover {
background: transparent;
}
.detail-panel {
display: grid;
gap: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
padding: var(--space-4);
min-height: 500px;
}
.detail-panel.empty {
display: grid;
align-content: center;
justify-items: center;
text-align: center;
}
.detail-panel.empty h3 {
margin: 0 0 var(--space-2);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-3);
}
.detail-header h3 {
margin: 0;
}
.detail-chips {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
}
.request-id {
margin: var(--space-2) 0 0;
display: flex;
align-items: center;
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;
color: var(--color-text);
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: 7px;
padding: 3px 8px;
}
.loading-detail {
margin: 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: var(--space-2);
margin: 0;
}
.meta-item {
margin: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-neutral-100);
display: grid;
gap: 4px;
}
.meta-item dt {
margin: 0;
font-size: 0.78rem;
font-weight: 700;
color: var(--color-text-muted);
}
.meta-item dd {
margin: 0;
overflow-wrap: anywhere;
font-size: 0.93rem;
}
.message-box {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-neutral-100);
padding: var(--space-3);
}
.message-box h4 {
margin: 0 0 var(--space-2);
font-size: 0.86rem;
color: var(--color-text-muted);
}
.message-box p {
margin: 0;
white-space: pre-wrap;
}
.attachments h4 {
margin: 0 0 var(--space-2);
}
.attachment-list {
display: grid;
gap: var(--space-2);
}
.attachment-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
padding: var(--space-3);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
box-shadow: var(--shadow-sm);
}
.filename {
margin: 0;
font-weight: 600;
font-size: 0.92rem;
}
.meta {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.muted {
color: var(--color-text-muted);
margin: 0;
}
.error {
color: var(--color-danger-500);
margin-bottom: var(--space-3);
}
.chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid transparent;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
padding: 5px 9px;
text-transform: uppercase;
}
.chip-neutral {
background: #e9f4ff;
border-color: #c8def4;
color: #1e4d78;
}
.chip-light {
background: #f4f5f8;
border-color: #dde1e8;
color: #4a5567;
}
.chip-warning {
background: #fff4cd;
border-color: #f7dd85;
color: #684b00;
}
.chip-success {
background: #dff6ea;
border-color: #b6e2cb;
color: #14543a;
}
.chip-danger {
background: #fde4e2;
border-color: #f3c0ba;
color: #812924;
}
button:disabled {
opacity: 0.68;
cursor: default;
}
@media (max-width: 1060px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.section-card {
padding: var(--space-4);
}
.section-header {
flex-direction: column;
align-items: stretch;
}
.detail-header {
flex-direction: column;
}
.detail-chips {
justify-content: flex-start;
}
.attachment-item {
flex-direction: column;
align-items: flex-start;
padding: var(--space-3);
}
}

View File

@@ -1,6 +1,11 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminContactRequest, AdminOperationsService } from '../services/admin-operations.service';
import {
AdminContactRequest,
AdminContactRequestAttachment,
AdminContactRequestDetail,
AdminOperationsService
} from '../services/admin-operations.service';
@Component({
selector: 'app-admin-contact-requests',
@@ -13,7 +18,10 @@ export class AdminContactRequestsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
requests: AdminContactRequest[] = [];
selectedRequest: AdminContactRequestDetail | null = null;
selectedRequestId: string | null = null;
loading = false;
detailLoading = false;
errorMessage: string | null = null;
ngOnInit(): void {
@@ -26,6 +34,14 @@ export class AdminContactRequestsComponent implements OnInit {
this.adminOperationsService.getContactRequests().subscribe({
next: (requests) => {
this.requests = requests;
if (requests.length === 0) {
this.selectedRequest = null;
this.selectedRequestId = null;
} else if (this.selectedRequestId && requests.some(r => r.id === this.selectedRequestId)) {
this.openDetails(this.selectedRequestId);
} else {
this.openDetails(requests[0].id);
}
this.loading = false;
},
error: () => {
@@ -34,4 +50,73 @@ export class AdminContactRequestsComponent implements OnInit {
}
});
}
openDetails(requestId: string): void {
this.selectedRequestId = requestId;
this.detailLoading = true;
this.adminOperationsService.getContactRequestDetail(requestId).subscribe({
next: (detail) => {
this.selectedRequest = detail;
this.detailLoading = false;
},
error: () => {
this.detailLoading = false;
this.errorMessage = 'Impossibile caricare il dettaglio richiesta.';
}
});
}
isSelected(requestId: string): boolean {
return this.selectedRequestId === requestId;
}
downloadAttachment(attachment: AdminContactRequestAttachment): void {
if (!this.selectedRequest) {
return;
}
this.adminOperationsService.downloadContactRequestAttachment(this.selectedRequest.id, attachment.id).subscribe({
next: (blob) => this.downloadBlob(blob, attachment.originalFilename || `attachment-${attachment.id}`),
error: () => {
this.errorMessage = 'Download allegato non riuscito.';
}
});
}
formatFileSize(bytes?: number): string {
if (!bytes || bytes <= 0) {
return '-';
}
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
getStatusChipClass(status?: string): string {
const normalized = (status || '').trim().toUpperCase();
if (['PENDING', 'NEW', 'OPEN', 'IN_PROGRESS'].includes(normalized)) {
return 'chip-warning';
}
if (['DONE', 'COMPLETED', 'RESOLVED', 'CLOSED'].includes(normalized)) {
return 'chip-success';
}
if (['REJECTED', 'FAILED', 'ERROR', 'SPAM'].includes(normalized)) {
return 'chip-danger';
}
return 'chip-light';
}
private downloadBlob(blob: Blob, filename: string): void {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
}
}

View File

@@ -15,14 +15,36 @@
<section class="list-panel">
<h2>Lista ordini</h2>
<div class="list-toolbar">
<label for="order-search">Cerca UUID</label>
<input
id="order-search"
type="search"
[ngModel]="orderSearchTerm"
(ngModelChange)="onSearchChange($event)"
placeholder="UUID completo o prefisso (es. 738131d8)"
/>
<label class="toolbar-field" for="order-search">
<span>Cerca UUID</span>
<input
id="order-search"
type="search"
[ngModel]="orderSearchTerm"
(ngModelChange)="onSearchChange($event)"
placeholder="UUID completo o prefisso (es. 738131d8)"
/>
</label>
<label class="toolbar-field" for="payment-status-filter">
<span>Stato pagamento</span>
<select
id="payment-status-filter"
[ngModel]="paymentStatusFilter"
(ngModelChange)="onPaymentStatusFilterChange($event)"
>
<option *ngFor="let option of paymentStatusFilterOptions" [ngValue]="option">{{ option }}</option>
</select>
</label>
<label class="toolbar-field" for="order-status-filter">
<span>Stato ordine</span>
<select
id="order-status-filter"
[ngModel]="orderStatusFilter"
(ngModelChange)="onOrderStatusFilterChange($event)"
>
<option *ngFor="let option of orderStatusFilterOptions" [ngValue]="option">{{ option }}</option>
</select>
</label>
</div>
<div class="table-wrap">
<table>
@@ -31,6 +53,7 @@
<th>Ordine</th>
<th>Email</th>
<th>Pagamento</th>
<th>Stato ordine</th>
<th>Totale</th>
</tr>
</thead>
@@ -43,10 +66,11 @@
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td>
<td>{{ order.status }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
</tr>
<tr class="no-results" *ngIf="filteredOrders.length === 0">
<td colspan="4">Nessun ordine trovato per il filtro inserito.</td>
<td colspan="5">Nessun ordine trovato per i filtri selezionati.</td>
</tr>
</tbody>
</table>

View File

@@ -31,7 +31,7 @@
.workspace {
display: grid;
grid-template-columns: minmax(400px, 0.95fr) minmax(560px, 1.45fr);
grid-template-columns: minmax(540px, 1.35fr) minmax(420px, 0.95fr);
gap: var(--space-4);
align-items: start;
}
@@ -70,17 +70,24 @@ button:disabled {
.list-toolbar {
display: grid;
gap: var(--space-1);
grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(190px, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.list-toolbar label {
.toolbar-field {
display: grid;
gap: var(--space-1);
}
.toolbar-field span {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-muted);
}
.list-toolbar input {
.toolbar-field input,
.toolbar-field select {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
@@ -383,6 +390,10 @@ h4 {
}
@media (max-width: 820px) {
.list-toolbar {
grid-template-columns: 1fr;
}
.dashboard-header {
flex-direction: column;
}

View File

@@ -19,6 +19,8 @@ export class AdminDashboardComponent implements OnInit {
selectedStatus = '';
selectedPaymentMethod = 'OTHER';
orderSearchTerm = '';
paymentStatusFilter = 'ALL';
orderStatusFilter = 'ALL';
showPrintDetails = false;
loading = false;
detailLoading = false;
@@ -34,6 +36,16 @@ export class AdminDashboardComponent implements OnInit {
'CANCELLED'
];
readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER'];
readonly paymentStatusFilterOptions = ['ALL', 'PENDING', 'REPORTED', 'COMPLETED'];
readonly orderStatusFilterOptions = [
'ALL',
'PENDING_PAYMENT',
'PAID',
'IN_PRODUCTION',
'SHIPPED',
'COMPLETED',
'CANCELLED'
];
ngOnInit(): void {
this.loadOrders();
@@ -72,17 +84,17 @@ export class AdminDashboardComponent implements OnInit {
onSearchChange(value: string): void {
this.orderSearchTerm = value;
this.refreshFilteredOrders();
this.applyListFiltersAndSelection();
}
if (this.filteredOrders.length === 0) {
this.selectedOrder = null;
this.selectedStatus = '';
return;
}
onPaymentStatusFilterChange(value: string): void {
this.paymentStatusFilter = value || 'ALL';
this.applyListFiltersAndSelection();
}
if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) {
this.openDetails(this.filteredOrders[0].id);
}
onOrderStatusFilterChange(value: string): void {
this.orderStatusFilter = value || 'ALL';
this.applyListFiltersAndSelection();
}
openDetails(orderId: string): void {
@@ -225,23 +237,39 @@ export class AdminDashboardComponent implements OnInit {
private applyOrderUpdate(updatedOrder: AdminOrder): void {
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
this.refreshFilteredOrders();
this.applyListFiltersAndSelection();
this.selectedOrder = updatedOrder;
this.selectedStatus = updatedOrder.status;
this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod;
}
private refreshFilteredOrders(): void {
const term = this.orderSearchTerm.trim().toLowerCase();
if (!term) {
this.filteredOrders = [...this.orders];
private applyListFiltersAndSelection(): void {
this.refreshFilteredOrders();
if (this.filteredOrders.length === 0) {
this.selectedOrder = null;
this.selectedStatus = '';
return;
}
if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) {
this.openDetails(this.filteredOrders[0].id);
}
}
private refreshFilteredOrders(): void {
const term = this.orderSearchTerm.trim().toLowerCase();
this.filteredOrders = this.orders.filter((order) => {
const fullUuid = order.id.toLowerCase();
const shortUuid = (order.orderNumber || '').toLowerCase();
return fullUuid.includes(term) || shortUuid.includes(term);
const paymentStatus = (order.paymentStatus || 'PENDING').toUpperCase();
const orderStatus = (order.status || '').toUpperCase();
const matchesSearch = !term || fullUuid.includes(term) || shortUuid.includes(term);
const matchesPayment = this.paymentStatusFilter === 'ALL' || paymentStatus === this.paymentStatusFilter;
const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter;
return matchesSearch && matchesPayment && matchesOrderStatus;
});
}

View File

@@ -2,42 +2,229 @@
<header class="section-header">
<div>
<h2>Stock filamenti</h2>
<p>Monitoraggio quantità disponibili per variante.</p>
<p>Gestione materiali, varianti e stock per il calcolatore.</p>
</div>
<button type="button" (click)="loadStock()" [disabled]="loading">Aggiorna</button>
<button type="button" (click)="loadData()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="alerts">
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
</div>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Materiale</th>
<th>Variante</th>
<th>Colore</th>
<th>Spool</th>
<th>Kg totali</th>
<th>Stato</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of rows">
<td>{{ row.materialCode }}</td>
<td>{{ row.variantDisplayName }}</td>
<td>{{ row.colorName }}</td>
<td>{{ row.stockSpools | number:'1.0-3' }}</td>
<td>{{ row.stockKg | number:'1.0-3' }} kg</td>
<td>
<span class="badge low" *ngIf="isLowStock(row)">Basso</span>
<span class="badge ok" *ngIf="!isLowStock(row)">OK</span>
</td>
</tr>
</tbody>
</table>
<div class="content" *ngIf="!loading; else loadingTpl">
<section class="panel">
<h3>Inserimento rapido</h3>
<div class="create-grid">
<section class="subpanel">
<h4>Nuovo materiale</h4>
<div class="form-grid">
<label class="form-field form-field--wide">
<span>Codice materiale</span>
<input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." />
</label>
<label class="form-field form-field--wide">
<span>Etichetta tecnico</span>
<input
type="text"
[(ngModel)]="newMaterial.technicalTypeLabel"
[disabled]="!newMaterial.isTechnical"
placeholder="alta temperatura, rinforzato..."
/>
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="newMaterial.isFlexible" />
<span>Flessibile</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newMaterial.isTechnical" />
<span>Tecnico</span>
</label>
</div>
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial">
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }}
</button>
</section>
<section class="subpanel">
<h4>Nuova variante</h4>
<div class="form-grid">
<label class="form-field">
<span>Materiale</span>
<select [(ngModel)]="newVariant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
{{ material.materialCode }}
</option>
</select>
</label>
<label class="form-field">
<span>Nome variante</span>
<input type="text" [(ngModel)]="newVariant.variantDisplayName" placeholder="PLA Nero Opaco BrandX" />
</label>
<label class="form-field">
<span>Colore</span>
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." />
</label>
<label class="form-field">
<span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="newVariant.costChfPerKg" />
</label>
<label class="form-field">
<span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="newVariant.stockSpools" />
</label>
<label class="form-field">
<span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="newVariant.spoolNetKg" />
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isMatte" />
<span>Matte</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isSpecial" />
<span>Special</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="newVariant.isActive" />
<span>Attiva</span>
</label>
</div>
<p class="variant-meta">
Stock stimato: <strong>{{ computeStockKg(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-3' }} kg</strong>
</p>
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length">
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }}
</button>
</section>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>Materiali</h3>
<button type="button" class="panel-toggle" (click)="toggleMaterialsCollapsed()">
{{ materialsCollapsed ? 'Espandi' : 'Collassa' }}
</button>
</div>
<div *ngIf="!materialsCollapsed; else materialsCollapsedTpl">
<div class="material-grid">
<article class="material-card" *ngFor="let material of materials; trackBy: trackById">
<div class="form-grid">
<label class="form-field form-field--wide">
<span>Codice</span>
<input type="text" [(ngModel)]="material.materialCode" />
</label>
<label class="form-field form-field--wide">
<span>Etichetta tecnico</span>
<input type="text" [(ngModel)]="material.technicalTypeLabel" [disabled]="!material.isTechnical" />
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="material.isFlexible" />
<span>Flessibile</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="material.isTechnical" />
<span>Tecnico</span>
</label>
</div>
<button type="button" (click)="saveMaterial(material)" [disabled]="savingMaterialIds.has(material.id)">
{{ savingMaterialIds.has(material.id) ? 'Salvataggio...' : 'Salva materiale' }}
</button>
</article>
</div>
<p class="muted" *ngIf="materials.length === 0">Nessun materiale configurato.</p>
</div>
</section>
<section class="panel">
<h3>Varianti filamento</h3>
<div class="variant-grid">
<article class="variant-card" *ngFor="let variant of variants; trackBy: trackById">
<div class="variant-header">
<strong>{{ variant.variantDisplayName }}</strong>
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span>
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span>
</div>
<div class="form-grid">
<label class="form-field">
<span>Materiale</span>
<select [(ngModel)]="variant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
{{ material.materialCode }}
</option>
</select>
</label>
<label class="form-field">
<span>Nome variante</span>
<input type="text" [(ngModel)]="variant.variantDisplayName" />
</label>
<label class="form-field">
<span>Colore</span>
<input type="text" [(ngModel)]="variant.colorName" />
</label>
<label class="form-field">
<span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="variant.costChfPerKg" />
</label>
<label class="form-field">
<span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="variant.stockSpools" />
</label>
<label class="form-field">
<span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="variant.spoolNetKg" />
</label>
</div>
<div class="toggle-group">
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isMatte" />
<span>Matte</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isSpecial" />
<span>Special</span>
</label>
<label class="toggle">
<input type="checkbox" [(ngModel)]="variant.isActive" />
<span>Attiva</span>
</label>
</div>
<p class="variant-meta">
Totale stimato: <strong>{{ computeStockKg(variant.stockSpools, variant.spoolNetKg) | number:'1.0-3' }} kg</strong>
</p>
<button type="button" (click)="saveVariant(variant)" [disabled]="savingVariantIds.has(variant.id)">
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }}
</button>
</article>
</div>
<p class="muted" *ngIf="variants.length === 0">Nessuna variante configurata.</p>
</section>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento stock...</p>
<p>Caricamento filamenti...</p>
</ng-template>
<ng-template #materialsCollapsedTpl>
<p class="muted">Sezione collassata ({{ materials.length }} materiali).</p>
</ng-template>

View File

@@ -11,18 +11,168 @@
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-5);
margin-bottom: var(--space-4);
}
h2 {
.section-header h2 {
margin: 0;
}
p {
.section-header p {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
.alerts {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.content {
display: grid;
gap: var(--space-4);
}
.panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
background: var(--color-bg-card);
}
.panel > h3 {
margin: 0 0 var(--space-3);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.panel-header h3 {
margin: 0;
}
.create-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.subpanel {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-neutral-100);
}
.subpanel h4 {
margin: 0 0 var(--space-3);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2) var(--space-3);
margin-bottom: var(--space-3);
}
.form-field {
display: grid;
gap: var(--space-1);
}
.form-field--wide {
grid-column: 1 / -1;
}
.form-field > span {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 600;
}
input,
select {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
background: var(--color-bg-card);
font: inherit;
color: var(--color-text);
}
input:disabled,
select:disabled {
opacity: 0.65;
}
.toggle-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.35rem 0.65rem;
background: var(--color-bg-card);
}
.toggle input {
width: 16px;
height: 16px;
margin: 0;
}
.toggle span {
font-size: 0.88rem;
font-weight: 600;
}
.material-grid,
.variant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--space-3);
}
.material-card,
.variant-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-neutral-100);
padding: var(--space-3);
}
.variant-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.variant-header strong {
font-size: 1rem;
}
.variant-meta {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
color: var(--color-text-muted);
}
button {
border: 0;
border-radius: var(--radius-md);
@@ -38,27 +188,26 @@ button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
.table-wrap {
overflow: auto;
button:disabled {
opacity: 0.65;
cursor: default;
}
table {
width: 100%;
border-collapse: collapse;
.panel-toggle {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
color: var(--color-text);
}
th,
td {
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
.panel-toggle:hover:not(:disabled) {
background: var(--color-neutral-100);
}
.badge {
display: inline-block;
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.78rem;
font-size: 0.75rem;
font-weight: 700;
}
@@ -74,4 +223,27 @@ td {
.error {
color: var(--color-danger-500);
margin: 0;
}
.success {
color: #157347;
margin: 0;
}
.muted {
margin: 0;
color: var(--color-text-muted);
}
@media (max-width: 1080px) {
.create-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.form-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,41 +1,277 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminFilamentStockRow, AdminOperationsService } from '../services/admin-operations.service';
import { FormsModule } from '@angular/forms';
import {
AdminFilamentMaterialType,
AdminFilamentVariant,
AdminOperationsService,
AdminUpsertFilamentMaterialTypePayload,
AdminUpsertFilamentVariantPayload
} from '../services/admin-operations.service';
import { forkJoin } from 'rxjs';
@Component({
selector: 'app-admin-filament-stock',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, FormsModule],
templateUrl: './admin-filament-stock.component.html',
styleUrl: './admin-filament-stock.component.scss'
})
export class AdminFilamentStockComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
rows: AdminFilamentStockRow[] = [];
materials: AdminFilamentMaterialType[] = [];
variants: AdminFilamentVariant[] = [];
loading = false;
materialsCollapsed = true;
creatingMaterial = false;
creatingVariant = false;
savingMaterialIds = new Set<number>();
savingVariantIds = new Set<number>();
errorMessage: string | null = null;
successMessage: string | null = null;
newMaterial: AdminUpsertFilamentMaterialTypePayload = {
materialCode: '',
isFlexible: false,
isTechnical: false,
technicalTypeLabel: ''
};
newVariant: AdminUpsertFilamentVariantPayload = {
materialTypeId: 0,
variantDisplayName: '',
colorName: '',
isMatte: false,
isSpecial: false,
costChfPerKg: 0,
stockSpools: 0,
spoolNetKg: 1,
isActive: true
};
ngOnInit(): void {
this.loadStock();
this.loadData();
}
loadStock(): void {
loadData(): void {
this.loading = true;
this.errorMessage = null;
this.adminOperationsService.getFilamentStock().subscribe({
next: (rows) => {
this.rows = rows;
this.successMessage = null;
forkJoin({
materials: this.adminOperationsService.getFilamentMaterials(),
variants: this.adminOperationsService.getFilamentVariants()
}).subscribe({
next: ({ materials, variants }) => {
this.materials = this.sortMaterials(materials);
this.variants = this.sortVariants(variants);
if (!this.newVariant.materialTypeId && this.materials.length > 0) {
this.newVariant.materialTypeId = this.materials[0].id;
}
this.loading = false;
},
error: () => {
error: (err) => {
this.loading = false;
this.errorMessage = 'Impossibile caricare lo stock filamenti.';
this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare i filamenti.');
}
});
}
isLowStock(row: AdminFilamentStockRow): boolean {
return Number(row.stockKg) < 1;
createMaterial(): void {
if (this.creatingMaterial) {
return;
}
this.errorMessage = null;
this.successMessage = null;
this.creatingMaterial = true;
const payload: AdminUpsertFilamentMaterialTypePayload = {
materialCode: (this.newMaterial.materialCode || '').trim(),
isFlexible: !!this.newMaterial.isFlexible,
isTechnical: !!this.newMaterial.isTechnical,
technicalTypeLabel: this.newMaterial.isTechnical
? (this.newMaterial.technicalTypeLabel || '').trim()
: ''
};
this.adminOperationsService.createFilamentMaterial(payload).subscribe({
next: (created) => {
this.materials = this.sortMaterials([...this.materials, created]);
if (!this.newVariant.materialTypeId) {
this.newVariant.materialTypeId = created.id;
}
this.newMaterial = {
materialCode: '',
isFlexible: false,
isTechnical: false,
technicalTypeLabel: ''
};
this.creatingMaterial = false;
this.successMessage = 'Materiale aggiunto.';
},
error: (err) => {
this.creatingMaterial = false;
this.errorMessage = this.extractErrorMessage(err, 'Creazione materiale non riuscita.');
}
});
}
saveMaterial(material: AdminFilamentMaterialType): void {
if (this.savingMaterialIds.has(material.id)) {
return;
}
this.errorMessage = null;
this.successMessage = null;
this.savingMaterialIds.add(material.id);
const payload: AdminUpsertFilamentMaterialTypePayload = {
materialCode: (material.materialCode || '').trim(),
isFlexible: !!material.isFlexible,
isTechnical: !!material.isTechnical,
technicalTypeLabel: material.isTechnical ? (material.technicalTypeLabel || '').trim() : ''
};
this.adminOperationsService.updateFilamentMaterial(material.id, payload).subscribe({
next: (updated) => {
this.materials = this.sortMaterials(
this.materials.map((m) => (m.id === updated.id ? updated : m))
);
this.variants = this.variants.map((variant) => {
if (variant.materialTypeId !== updated.id) {
return variant;
}
return {
...variant,
materialCode: updated.materialCode,
materialIsFlexible: updated.isFlexible,
materialIsTechnical: updated.isTechnical,
materialTechnicalTypeLabel: updated.technicalTypeLabel
};
});
this.savingMaterialIds.delete(material.id);
this.successMessage = 'Materiale aggiornato.';
},
error: (err) => {
this.savingMaterialIds.delete(material.id);
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento materiale non riuscito.');
}
});
}
createVariant(): void {
if (this.creatingVariant) {
return;
}
this.errorMessage = null;
this.successMessage = null;
this.creatingVariant = true;
const payload = this.toVariantPayload(this.newVariant);
this.adminOperationsService.createFilamentVariant(payload).subscribe({
next: (created) => {
this.variants = this.sortVariants([...this.variants, created]);
this.newVariant = {
materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0,
variantDisplayName: '',
colorName: '',
isMatte: false,
isSpecial: false,
costChfPerKg: 0,
stockSpools: 0,
spoolNetKg: 1,
isActive: true
};
this.creatingVariant = false;
this.successMessage = 'Variante aggiunta.';
},
error: (err) => {
this.creatingVariant = false;
this.errorMessage = this.extractErrorMessage(err, 'Creazione variante non riuscita.');
}
});
}
saveVariant(variant: AdminFilamentVariant): void {
if (this.savingVariantIds.has(variant.id)) {
return;
}
this.errorMessage = null;
this.successMessage = null;
this.savingVariantIds.add(variant.id);
const payload = this.toVariantPayload(variant);
this.adminOperationsService.updateFilamentVariant(variant.id, payload).subscribe({
next: (updated) => {
this.variants = this.sortVariants(
this.variants.map((v) => (v.id === updated.id ? updated : v))
);
this.savingVariantIds.delete(variant.id);
this.successMessage = 'Variante aggiornata.';
},
error: (err) => {
this.savingVariantIds.delete(variant.id);
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento variante non riuscito.');
}
});
}
isLowStock(variant: AdminFilamentVariant): boolean {
return this.computeStockKg(variant.stockSpools, variant.spoolNetKg) < 1;
}
computeStockKg(stockSpools?: number, spoolNetKg?: number): number {
const spools = Number(stockSpools ?? 0);
const netKg = Number(spoolNetKg ?? 0);
if (!Number.isFinite(spools) || !Number.isFinite(netKg) || spools < 0 || netKg < 0) {
return 0;
}
return spools * netKg;
}
trackById(index: number, item: { id: number }): number {
return item.id;
}
toggleMaterialsCollapsed(): void {
this.materialsCollapsed = !this.materialsCollapsed;
}
private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload {
return {
materialTypeId: Number(source.materialTypeId),
variantDisplayName: (source.variantDisplayName || '').trim(),
colorName: (source.colorName || '').trim(),
isMatte: !!source.isMatte,
isSpecial: !!source.isSpecial,
costChfPerKg: Number(source.costChfPerKg ?? 0),
stockSpools: Number(source.stockSpools ?? 0),
spoolNetKg: Number(source.spoolNetKg ?? 0),
isActive: source.isActive !== false
};
}
private sortMaterials(materials: AdminFilamentMaterialType[]): AdminFilamentMaterialType[] {
return [...materials].sort((a, b) => a.materialCode.localeCompare(b.materialCode));
}
private sortVariants(variants: AdminFilamentVariant[]): AdminFilamentVariant[] {
return [...variants].sort((a, b) => {
const byMaterial = (a.materialCode || '').localeCompare(b.materialCode || '');
if (byMaterial !== 0) {
return byMaterial;
}
return (a.variantDisplayName || '').localeCompare(b.variantDisplayName || '');
});
}
private extractErrorMessage(error: unknown, fallback: string): string {
const err = error as { error?: { message?: string } };
return err?.error?.message || fallback;
}
}

View File

@@ -1,39 +0,0 @@
<section class="section-card">
<header class="section-header">
<div>
<h2>Ordini pagati</h2>
</div>
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Ordine</th>
<th>Data</th>
<th>Email</th>
<th>Stato ordine</th>
<th>Stato pagamento</th>
<th>Totale</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let order of orders">
<td>{{ order.orderNumber }}</td>
<td>{{ order.createdAt | date:'short' }}</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.status }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento ordini passati...</p>
</ng-template>

View File

@@ -1,59 +0,0 @@
.section-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
h2 {
margin: 0;
}
p {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
button {
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-2) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.error {
color: var(--color-danger-500);
}

View File

@@ -1,39 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service';
@Component({
selector: 'app-admin-orders-past',
standalone: true,
imports: [CommonModule],
templateUrl: './admin-orders-past.component.html',
styleUrl: './admin-orders-past.component.scss'
})
export class AdminOrdersPastComponent implements OnInit {
private readonly adminOrdersService = inject(AdminOrdersService);
orders: AdminOrder[] = [];
loading = false;
errorMessage: string | null = null;
ngOnInit(): void {
this.loadOrders();
}
loadOrders(): void {
this.loading = true;
this.errorMessage = null;
this.adminOrdersService.listOrders().subscribe({
next: (orders) => {
this.orders = orders.filter((order) =>
order.paymentStatus === 'COMPLETED' || order.status !== 'PENDING_PAYMENT'
);
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare gli ordini passati.';
}
});
}
}

View File

@@ -4,10 +4,11 @@
<h2>Sessioni quote</h2>
<p>Sessioni create dal configuratore con stato e conversione ordine.</p>
</div>
<button type="button" (click)="loadSessions()" [disabled]="loading">Aggiorna</button>
<button type="button" class="btn-primary" (click)="loadSessions()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
@@ -19,17 +20,75 @@
<th>Materiale</th>
<th>Stato</th>
<th>Ordine convertito</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let session of sessions">
<td>{{ session.id }}</td>
<td>{{ session.createdAt | date:'short' }}</td>
<td>{{ session.expiresAt | date:'short' }}</td>
<td>{{ session.materialCode }}</td>
<td>{{ session.status }}</td>
<td>{{ session.convertedOrderId || '-' }}</td>
</tr>
<ng-container *ngFor="let session of sessions">
<tr>
<td [title]="session.id">{{ session.id | slice:0:8 }}</td>
<td>{{ session.createdAt | date:'short' }}</td>
<td>{{ session.expiresAt | date:'short' }}</td>
<td>{{ session.materialCode }}</td>
<td>{{ session.status }}</td>
<td>{{ session.convertedOrderId || '-' }}</td>
<td class="actions">
<button
type="button"
class="btn-secondary"
(click)="toggleSessionDetail(session)">
{{ isDetailOpen(session.id) ? 'Nascondi' : 'Vedi' }}
</button>
<button
type="button"
class="btn-danger"
(click)="deleteSession(session)"
[disabled]="isDeletingSession(session.id) || !!session.convertedOrderId"
[title]="session.convertedOrderId ? 'Sessione collegata a un ordine, non eliminabile.' : ''">
{{ isDeletingSession(session.id) ? 'Eliminazione...' : 'Elimina' }}
</button>
</td>
</tr>
<tr *ngIf="isDetailOpen(session.id)">
<td colspan="7" class="detail-cell">
<div *ngIf="isLoadingDetail(session.id)">Caricamento dettaglio...</div>
<div *ngIf="!isLoadingDetail(session.id) && getSessionDetail(session.id) as detail" class="detail-box">
<div class="detail-summary">
<div><strong>Elementi:</strong> {{ detail.items.length }}</div>
<div><strong>Totale articoli:</strong> {{ detail.itemsTotalChf | currency:'CHF' }}</div>
<div><strong>Spedizione:</strong> {{ detail.shippingCostChf | currency:'CHF' }}</div>
<div><strong>Totale sessione:</strong> {{ detail.grandTotalChf | currency:'CHF' }}</div>
</div>
<table class="detail-table" *ngIf="detail.items.length > 0; else noItemsTpl">
<thead>
<tr>
<th>File</th>
<th>Qta</th>
<th>Tempo</th>
<th>Materiale</th>
<th>Stato</th>
<th>Prezzo unit.</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of detail.items">
<td>{{ item.originalFilename }}</td>
<td>{{ item.quantity }}</td>
<td>{{ formatPrintTime(item.printTimeSeconds) }}</td>
<td>{{ item.materialGrams ? (item.materialGrams | number:'1.0-2') + ' g' : '-' }}</td>
<td>{{ item.status }}</td>
<td>{{ item.unitPriceChf | currency:'CHF' }}</td>
</tr>
</tbody>
</table>
<ng-template #noItemsTpl>
<p class="muted">Nessun elemento in questa sessione.</p>
</ng-template>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>

View File

@@ -26,18 +26,40 @@ p {
button {
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-2) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover:not(:disabled) {
.btn-primary {
background: var(--color-brand);
color: var(--color-neutral-900);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-brand-hover);
}
.btn-danger {
background: var(--color-danger-500);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
}
.btn-secondary {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-neutral-100);
}
.table-wrap {
overflow: auto;
}
@@ -57,3 +79,48 @@ td {
.error {
color: var(--color-danger-500);
}
.success {
color: var(--color-success-500);
}
.actions {
display: flex;
gap: var(--space-2);
white-space: nowrap;
}
.detail-cell {
background: var(--color-neutral-100);
padding: var(--space-4);
}
.detail-box {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
padding: var(--space-4);
}
.detail-summary {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.detail-table {
width: 100%;
border-collapse: collapse;
}
.detail-table th,
.detail-table td {
text-align: left;
padding: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.muted {
color: var(--color-text-muted);
}

View File

@@ -1,6 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminOperationsService, AdminQuoteSession } from '../services/admin-operations.service';
import {
AdminOperationsService,
AdminQuoteSession,
AdminQuoteSessionDetail
} from '../services/admin-operations.service';
@Component({
selector: 'app-admin-sessions',
@@ -13,8 +17,13 @@ export class AdminSessionsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
sessions: AdminQuoteSession[] = [];
sessionDetailsById: Record<string, AdminQuoteSessionDetail | undefined> = {};
loading = false;
deletingSessionIds = new Set<string>();
loadingDetailSessionIds = new Set<string>();
expandedSessionId: string | null = null;
errorMessage: string | null = null;
successMessage: string | null = null;
ngOnInit(): void {
this.loadSessions();
@@ -23,6 +32,7 @@ export class AdminSessionsComponent implements OnInit {
loadSessions(): void {
this.loading = true;
this.errorMessage = null;
this.successMessage = null;
this.adminOperationsService.getSessions().subscribe({
next: (sessions) => {
this.sessions = sessions;
@@ -34,4 +44,90 @@ export class AdminSessionsComponent implements OnInit {
}
});
}
deleteSession(session: AdminQuoteSession): void {
if (this.deletingSessionIds.has(session.id)) {
return;
}
const confirmed = window.confirm(
`Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.`
);
if (!confirmed) {
return;
}
this.errorMessage = null;
this.successMessage = null;
this.deletingSessionIds.add(session.id);
this.adminOperationsService.deleteSession(session.id).subscribe({
next: () => {
this.sessions = this.sessions.filter((item) => item.id !== session.id);
this.deletingSessionIds.delete(session.id);
this.successMessage = 'Sessione eliminata.';
},
error: (err) => {
this.deletingSessionIds.delete(session.id);
this.errorMessage = this.extractErrorMessage(err, 'Impossibile eliminare la sessione.');
}
});
}
isDeletingSession(sessionId: string): boolean {
return this.deletingSessionIds.has(sessionId);
}
toggleSessionDetail(session: AdminQuoteSession): void {
if (this.expandedSessionId === session.id) {
this.expandedSessionId = null;
return;
}
this.expandedSessionId = session.id;
if (this.sessionDetailsById[session.id] || this.loadingDetailSessionIds.has(session.id)) {
return;
}
this.loadingDetailSessionIds.add(session.id);
this.adminOperationsService.getSessionDetail(session.id).subscribe({
next: (detail) => {
this.sessionDetailsById = {
...this.sessionDetailsById,
[session.id]: detail
};
this.loadingDetailSessionIds.delete(session.id);
},
error: (err) => {
this.loadingDetailSessionIds.delete(session.id);
this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare il dettaglio sessione.');
}
});
}
isDetailOpen(sessionId: string): boolean {
return this.expandedSessionId === sessionId;
}
isLoadingDetail(sessionId: string): boolean {
return this.loadingDetailSessionIds.has(sessionId);
}
getSessionDetail(sessionId: string): AdminQuoteSessionDetail | undefined {
return this.sessionDetailsById[sessionId];
}
formatPrintTime(seconds?: number): string {
if (!seconds || seconds <= 0) {
return '-';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
private extractErrorMessage(error: unknown, fallback: string): string {
const err = error as { error?: { message?: string } };
return err?.error?.message || fallback;
}
}

View File

@@ -8,7 +8,6 @@
<nav class="menu">
<a routerLink="orders" routerLinkActive="active">Ordini</a>
<a routerLink="orders-past" routerLinkActive="active">Ordini passati</a>
<a routerLink="filament-stock" routerLinkActive="active">Stock filamenti</a>
<a routerLink="contact-requests" routerLinkActive="active">Richieste contatto</a>
<a routerLink="sessions" routerLinkActive="active">Sessioni</a>

View File

@@ -14,6 +14,52 @@ export interface AdminFilamentStockRow {
active: boolean;
}
export interface AdminFilamentMaterialType {
id: number;
materialCode: string;
isFlexible: boolean;
isTechnical: boolean;
technicalTypeLabel?: string;
}
export interface AdminFilamentVariant {
id: number;
materialTypeId: number;
materialCode: string;
materialIsFlexible: boolean;
materialIsTechnical: boolean;
materialTechnicalTypeLabel?: string;
variantDisplayName: string;
colorName: string;
isMatte: boolean;
isSpecial: boolean;
costChfPerKg: number;
stockSpools: number;
spoolNetKg: number;
stockKg: number;
isActive: boolean;
createdAt: string;
}
export interface AdminUpsertFilamentMaterialTypePayload {
materialCode: string;
isFlexible: boolean;
isTechnical: boolean;
technicalTypeLabel?: string;
}
export interface AdminUpsertFilamentVariantPayload {
materialTypeId: number;
variantDisplayName: string;
colorName: string;
isMatte: boolean;
isSpecial: boolean;
costChfPerKg: number;
stockSpools: number;
spoolNetKg: number;
isActive: boolean;
}
export interface AdminContactRequest {
id: string;
requestType: string;
@@ -26,6 +72,30 @@ export interface AdminContactRequest {
createdAt: string;
}
export interface AdminContactRequestAttachment {
id: string;
originalFilename: string;
mimeType?: string;
fileSizeBytes?: number;
createdAt: string;
}
export interface AdminContactRequestDetail {
id: string;
requestType: string;
customerType: string;
email: string;
phone?: string;
name?: string;
companyName?: string;
contactPerson?: string;
message: string;
status: string;
createdAt: string;
updatedAt: string;
attachments: AdminContactRequestAttachment[];
}
export interface AdminQuoteSession {
id: string;
status: string;
@@ -35,6 +105,33 @@ export interface AdminQuoteSession {
convertedOrderId?: string;
}
export interface AdminQuoteSessionDetailItem {
id: string;
originalFilename: string;
quantity: number;
printTimeSeconds?: number;
materialGrams?: number;
colorCode?: string;
status: string;
unitPriceChf: number;
}
export interface AdminQuoteSessionDetail {
session: {
id: string;
status: string;
materialCode: string;
setupCostChf?: number;
supportsEnabled?: boolean;
notes?: string;
};
items: AdminQuoteSessionDetailItem[];
itemsTotalChf: number;
shippingCostChf: number;
globalMachineCostChf: number;
grandTotalChf: number;
}
@Injectable({
providedIn: 'root'
})
@@ -46,11 +143,57 @@ export class AdminOperationsService {
return this.http.get<AdminFilamentStockRow[]>(`${this.baseUrl}/filament-stock`, { withCredentials: true });
}
getFilamentMaterials(): Observable<AdminFilamentMaterialType[]> {
return this.http.get<AdminFilamentMaterialType[]>(`${this.baseUrl}/filaments/materials`, { withCredentials: true });
}
getFilamentVariants(): Observable<AdminFilamentVariant[]> {
return this.http.get<AdminFilamentVariant[]>(`${this.baseUrl}/filaments/variants`, { withCredentials: true });
}
createFilamentMaterial(payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> {
return this.http.post<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials`, payload, { withCredentials: true });
}
updateFilamentMaterial(materialId: number, payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> {
return this.http.put<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials/${materialId}`, payload, { withCredentials: true });
}
createFilamentVariant(payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> {
return this.http.post<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants`, payload, { withCredentials: true });
}
updateFilamentVariant(variantId: number, payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> {
return this.http.put<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true });
}
getContactRequests(): Observable<AdminContactRequest[]> {
return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true });
}
getContactRequestDetail(requestId: string): Observable<AdminContactRequestDetail> {
return this.http.get<AdminContactRequestDetail>(`${this.baseUrl}/contact-requests/${requestId}`, { withCredentials: true });
}
downloadContactRequestAttachment(requestId: string, attachmentId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`, {
withCredentials: true,
responseType: 'blob'
});
}
getSessions(): Observable<AdminQuoteSession[]> {
return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, { withCredentials: true });
}
deleteSession(sessionId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/sessions/${sessionId}`, { withCredentials: true });
}
getSessionDetail(sessionId: string): Observable<AdminQuoteSessionDetail> {
return this.http.get<AdminQuoteSessionDetail>(
`${environment.apiUrl}/api/quote-sessions/${sessionId}`,
{ withCredentials: true }
);
}
}