feat(back-end and front-end): back-office

This commit is contained in:
2026-02-27 15:07:32 +01:00
parent 65e1ee3be6
commit 949770a741
38 changed files with 2558 additions and 345 deletions

View File

@@ -9,6 +9,33 @@ export const ADMIN_ROUTES: Routes = [
{
path: '',
canActivate: [adminAuthGuard],
loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent)
loadComponent: () => import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
children: [
{
path: '',
pathMatch: 'full',
redirectTo: 'orders'
},
{
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)
},
{
path: 'contact-requests',
loadComponent: () => import('./pages/admin-contact-requests.component').then(m => m.AdminContactRequestsComponent)
},
{
path: 'sessions',
loadComponent: () => import('./pages/admin-sessions.component').then(m => m.AdminSessionsComponent)
}
]
}
];

View File

@@ -0,0 +1,40 @@
<section class="section-card">
<header class="section-header">
<div>
<h2>Richieste di contatto</h2>
<p>Richieste preventivo personalizzato ricevute dal sito.</p>
</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>
</section>
<ng-template #loadingTpl>
<p>Caricamento richieste...</p>
</ng-template>

View File

@@ -0,0 +1,59 @@
.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

@@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminContactRequest, AdminOperationsService } from '../services/admin-operations.service';
@Component({
selector: 'app-admin-contact-requests',
standalone: true,
imports: [CommonModule],
templateUrl: './admin-contact-requests.component.html',
styleUrl: './admin-contact-requests.component.scss'
})
export class AdminContactRequestsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
requests: AdminContactRequest[] = [];
loading = false;
errorMessage: string | null = null;
ngOnInit(): void {
this.loadRequests();
}
loadRequests(): void {
this.loading = true;
this.errorMessage = null;
this.adminOperationsService.getContactRequests().subscribe({
next: (requests) => {
this.requests = requests;
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare le richieste di contatto.';
}
});
}
}

View File

@@ -1,67 +1,167 @@
<section class="admin-dashboard">
<header class="dashboard-header">
<div>
<h1>Back-office ordini</h1>
<p>Gestione pagamenti e dettaglio ordini</p>
<h1>Ordini</h1>
<p>Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.</p>
</div>
<div class="header-actions">
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
<button type="button" class="ghost" (click)="logout()">Logout</button>
</div>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Ordine</th>
<th>Email</th>
<th>Stato</th>
<th>Pagamento</th>
<th>Totale</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let order of orders" [class.selected]="selectedOrder?.id === order.id">
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.status }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
<td class="actions">
<button type="button" class="ghost" (click)="openDetails(order.id)">Dettaglio</button>
<button
type="button"
(click)="confirmPayment(order.id)"
[disabled]="confirmingOrderId === order.id || order.paymentStatus === 'COMPLETED'"
>
{{ confirmingOrderId === order.id ? 'Invio...' : 'Conferma pagamento' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<section class="details" *ngIf="selectedOrder">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<p *ngIf="detailLoading">Caricamento dettaglio...</p>
<p><strong>Cliente:</strong> {{ selectedOrder.customerEmail }}</p>
<p><strong>Pagamento:</strong> {{ selectedOrder.paymentStatus || 'PENDING' }}</p>
<div class="items">
<div class="item" *ngFor="let item of selectedOrder.items">
<p><strong>File:</strong> {{ item.originalFilename }}</p>
<p><strong>Qta:</strong> {{ item.quantity }}</p>
<p><strong>Prezzo riga:</strong> {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}</p>
<div class="workspace" *ngIf="!loading; else loadingTpl">
<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)"
/>
</div>
</div>
</section>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Ordine</th>
<th>Email</th>
<th>Pagamento</th>
<th>Totale</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let order of filteredOrders"
[class.selected]="isSelected(order.id)"
(click)="openDetails(order.id)"
>
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</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>
</tr>
</tbody>
</table>
</div>
</section>
<section class="detail-panel" *ngIf="selectedOrder">
<div class="detail-header">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<p class="order-uuid">UUID: <code>{{ selectedOrder.id }}</code></p>
<p *ngIf="detailLoading">Caricamento dettaglio...</p>
</div>
<div class="meta-grid">
<div><strong>Cliente</strong><span>{{ selectedOrder.customerEmail }}</span></div>
<div><strong>Stato pagamento</strong><span>{{ selectedOrder.paymentStatus || 'PENDING' }}</span></div>
<div><strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span></div>
<div><strong>Totale</strong><span>{{ selectedOrder.totalChf | currency:'CHF':'symbol':'1.2-2' }}</span></div>
</div>
<div class="actions-block">
<div class="status-editor">
<label for="order-status">Stato ordine</label>
<select id="order-status" [value]="selectedStatus" (change)="onStatusChange($event)">
<option *ngFor="let option of orderStatusOptions" [value]="option">{{ option }}</option>
</select>
<button type="button" (click)="updateStatus()" [disabled]="updatingStatus">
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }}
</button>
</div>
<div class="status-editor">
<label for="payment-method">Metodo pagamento</label>
<select id="payment-method" [value]="selectedPaymentMethod" (change)="onPaymentMethodChange($event)">
<option *ngFor="let option of paymentMethodOptions" [value]="option">{{ option }}</option>
</select>
<button
type="button"
(click)="confirmPayment()"
[disabled]="confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'"
>
{{ confirmingPayment ? 'Invio...' : 'Conferma pagamento' }}
</button>
</div>
</div>
<div class="doc-actions">
<button type="button" class="ghost" (click)="downloadConfirmation()">
Scarica conferma + QR bill
</button>
<button type="button" class="ghost" (click)="downloadInvoice()">
Scarica fattura
</button>
<button type="button" class="ghost" (click)="openPrintDetails()">
Dettagli stampa
</button>
</div>
<div class="items">
<div class="item" *ngFor="let item of selectedOrder.items">
<div class="item-main">
<p class="file-name"><strong>{{ item.originalFilename }}</strong></p>
<p class="item-meta">
Qta: {{ item.quantity }} |
Colore:
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span>
<span>{{ item.colorCode || '-' }}</span>
|
Riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}
</p>
</div>
<button type="button" class="ghost" (click)="downloadItemFile(item.id, item.originalFilename)">
Scarica file
</button>
</div>
</div>
</section>
<section class="detail-panel empty" *ngIf="!selectedOrder">
<h2>Nessun ordine selezionato</h2>
<p>Seleziona un ordine dalla lista per vedere i dettagli.</p>
</section>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento ordini...</p>
</ng-template>
<div class="modal-backdrop" *ngIf="showPrintDetails && selectedOrder" (click)="closePrintDetails()">
<div class="modal-card" (click)="$event.stopPropagation()">
<header class="modal-header">
<h3>Dettagli stampa ordine {{ selectedOrder.orderNumber }}</h3>
<button type="button" class="ghost close-btn" (click)="closePrintDetails()">Chiudi</button>
</header>
<div class="modal-grid">
<div><strong>Qualità</strong><span>{{ getQualityLabel(selectedOrder.printLayerHeightMm) }}</span></div>
<div><strong>Materiale</strong><span>{{ selectedOrder.printMaterialCode || '-' }}</span></div>
<div><strong>Layer height</strong><span>{{ selectedOrder.printLayerHeightMm || '-' }} mm</span></div>
<div><strong>Nozzle</strong><span>{{ selectedOrder.printNozzleDiameterMm || '-' }} mm</span></div>
<div><strong>Infill pattern</strong><span>{{ selectedOrder.printInfillPattern || '-' }}</span></div>
<div><strong>Infill %</strong><span>{{ selectedOrder.printInfillPercent ?? '-' }}</span></div>
<div><strong>Supporti</strong><span>{{ selectedOrder.printSupportsEnabled ? 'Sì' : 'No' }}</span></div>
</div>
<h4>Colori file</h4>
<div class="file-color-list">
<div class="file-color-row" *ngFor="let item of selectedOrder.items">
<span class="filename">{{ item.originalFilename }}</span>
<span class="file-color">
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span>
{{ item.colorCode || '-' }}
</span>
</div>
</div>
</div>
</div>

View File

@@ -1,43 +1,61 @@
.admin-dashboard {
padding: 1rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-5);
box-shadow: var(--shadow-sm);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.dashboard-header h1 {
margin: 0;
font-size: 1.6rem;
font-size: 1.45rem;
}
.dashboard-header p {
margin: 0.35rem 0 0;
color: #4b5a70;
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
}
.header-actions {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
}
.workspace {
display: grid;
grid-template-columns: minmax(400px, 0.95fr) minmax(560px, 1.45fr);
gap: var(--space-4);
align-items: start;
}
button {
border: 0;
border-radius: 10px;
background: #0f3f6f;
color: #fff;
padding: 0.55rem 0.8rem;
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;
line-height: 1.2;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
button.ghost {
background: #eef2f8;
color: #163a5f;
background: var(--color-bg-card);
color: var(--color-text);
border: 1px solid var(--color-border);
}
button:disabled {
@@ -45,11 +63,38 @@ button:disabled {
cursor: default;
}
.list-panel h2 {
font-size: 1.05rem;
margin-bottom: var(--space-2);
}
.list-toolbar {
display: grid;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.list-toolbar label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-muted);
}
.list-toolbar input {
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;
}
.table-wrap {
overflow: auto;
border: 1px solid #d8e0ec;
border-radius: 12px;
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
max-height: 72vh;
}
table {
@@ -58,62 +103,296 @@ table {
}
thead {
background: #f3f6fa;
background: var(--color-neutral-100);
}
th,
td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid #e5ebf4;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
vertical-align: top;
font-size: 0.93rem;
}
td.actions {
tbody tr {
cursor: pointer;
transition: background-color 0.15s ease;
}
tbody tr:hover {
background: #fff9d9;
}
tbody tr.selected {
background: #fff5b8;
}
tbody tr.no-results {
cursor: default;
}
tbody tr.no-results:hover {
background: transparent;
}
.detail-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
min-height: 520px;
}
.detail-panel.empty {
display: grid;
align-content: center;
justify-items: center;
text-align: center;
color: var(--color-text-muted);
}
.order-uuid {
font-size: 0.84rem;
color: var(--color-text-muted);
}
.order-uuid code {
font-size: 0.82rem;
color: var(--color-text);
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 2px 6px;
}
.detail-header h2 {
margin: 0 0 var(--space-2);
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.meta-grid > div {
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
display: grid;
gap: 2px;
}
.meta-grid strong {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.actions-block {
display: flex;
gap: 0.5rem;
min-width: 210px;
flex-wrap: wrap;
gap: var(--space-3);
align-items: flex-end;
margin-bottom: var(--space-3);
}
tr.selected {
background: #f4f9ff;
.status-editor {
display: grid;
gap: var(--space-2);
}
.details {
margin-top: 1rem;
background: #fff;
border: 1px solid #d8e0ec;
border-radius: 12px;
padding: 1rem;
.status-editor label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
}
.details h2 {
margin-top: 0;
.status-editor select {
min-width: 220px;
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;
}
.doc-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.items {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-3);
}
.item {
border: 1px solid #e5ebf4;
border-radius: 10px;
padding: 0.65rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-bg-card);
box-shadow: var(--shadow-sm);
display: grid;
gap: var(--space-3);
}
.item p {
margin: 0.2rem 0;
.item-main {
min-width: 0;
}
.file-name {
margin: 0;
font-size: 0.92rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
margin: var(--space-1) 0 0;
font-size: 0.84rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.item button {
justify-self: start;
}
.color-swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid var(--color-border);
}
.error {
color: #b4232c;
margin-bottom: 0.9rem;
color: var(--color-danger-500);
margin-bottom: var(--space-3);
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(16, 24, 32, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 4000;
padding: var(--space-4);
}
.modal-card {
width: min(860px, 100%);
max-height: 88vh;
overflow: auto;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: var(--space-4);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.modal-header h3 {
margin: 0;
}
.close-btn {
white-space: nowrap;
}
.modal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.modal-grid > div {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-neutral-100);
padding: var(--space-3);
display: grid;
gap: 2px;
}
.modal-grid strong {
font-size: 0.78rem;
color: var(--color-text-muted);
}
h4 {
margin: 0 0 var(--space-2);
}
.file-color-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.file-color-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-card);
}
.file-color-row:last-child {
border-bottom: 0;
}
.filename {
font-size: 0.9rem;
}
.file-color {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--color-text-muted);
font-size: 0.88rem;
}
@media (max-width: 1060px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 820px) {
.dashboard-header {
flex-direction: column;
}
.meta-grid,
.modal-grid {
grid-template-columns: 1fr;
}
.item {
align-items: flex-start;
}
}

View File

@@ -1,30 +1,39 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminAuthService } from '../services/admin-auth.service';
import { FormsModule } from '@angular/forms';
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@Component({
selector: 'app-admin-dashboard',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, FormsModule],
templateUrl: './admin-dashboard.component.html',
styleUrl: './admin-dashboard.component.scss'
})
export class AdminDashboardComponent implements OnInit {
private readonly adminOrdersService = inject(AdminOrdersService);
private readonly adminAuthService = inject(AdminAuthService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
orders: AdminOrder[] = [];
filteredOrders: AdminOrder[] = [];
selectedOrder: AdminOrder | null = null;
selectedStatus = '';
selectedPaymentMethod = 'OTHER';
orderSearchTerm = '';
showPrintDetails = false;
loading = false;
detailLoading = false;
confirmingOrderId: string | null = null;
confirmingPayment = false;
updatingStatus = false;
errorMessage: string | null = null;
readonly orderStatusOptions = [
'PENDING_PAYMENT',
'PAID',
'IN_PRODUCTION',
'SHIPPED',
'COMPLETED',
'CANCELLED'
];
readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER'];
ngOnInit(): void {
this.loadOrders();
@@ -36,6 +45,22 @@ export class AdminDashboardComponent implements OnInit {
this.adminOrdersService.listOrders().subscribe({
next: (orders) => {
this.orders = orders;
this.refreshFilteredOrders();
if (!this.selectedOrder && this.filteredOrders.length > 0) {
this.openDetails(this.filteredOrders[0].id);
} else if (this.selectedOrder) {
const exists = orders.find(order => order.id === this.selectedOrder?.id);
const selectedIsVisible = this.filteredOrders.some(order => order.id === this.selectedOrder?.id);
if (exists && selectedIsVisible) {
this.openDetails(exists.id);
} else if (this.filteredOrders.length > 0) {
this.openDetails(this.filteredOrders[0].id);
} else {
this.selectedOrder = null;
this.selectedStatus = '';
}
}
this.loading = false;
},
error: () => {
@@ -45,11 +70,28 @@ export class AdminDashboardComponent implements OnInit {
});
}
onSearchChange(value: string): void {
this.orderSearchTerm = value;
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);
}
}
openDetails(orderId: string): void {
this.detailLoading = true;
this.adminOrdersService.getOrder(orderId).subscribe({
next: (order) => {
this.selectedOrder = order;
this.selectedStatus = order.status;
this.selectedPaymentMethod = order.paymentMethod || 'OTHER';
this.detailLoading = false;
},
error: () => {
@@ -59,41 +101,156 @@ export class AdminDashboardComponent implements OnInit {
});
}
confirmPayment(orderId: string): void {
this.confirmingOrderId = orderId;
this.adminOrdersService.confirmPayment(orderId).subscribe({
confirmPayment(): void {
if (!this.selectedOrder || this.confirmingPayment) {
return;
}
this.confirmingPayment = true;
this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({
next: (updatedOrder) => {
this.confirmingOrderId = null;
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
if (this.selectedOrder?.id === updatedOrder.id) {
this.selectedOrder = updatedOrder;
}
this.confirmingPayment = false;
this.applyOrderUpdate(updatedOrder);
},
error: () => {
this.confirmingOrderId = null;
this.confirmingPayment = false;
this.errorMessage = 'Conferma pagamento non riuscita.';
}
});
}
logout(): void {
this.adminAuthService.logout().subscribe({
next: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
updateStatus(): void {
if (!this.selectedOrder || this.updatingStatus || !this.selectedStatus.trim()) {
return;
}
this.updatingStatus = true;
this.adminOrdersService.updateOrderStatus(this.selectedOrder.id, {
status: this.selectedStatus.trim()
}).subscribe({
next: (updatedOrder) => {
this.updatingStatus = false;
this.applyOrderUpdate(updatedOrder);
},
error: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
this.updatingStatus = false;
this.errorMessage = 'Aggiornamento stato ordine non riuscito.';
}
});
}
private resolveLang(): string {
for (const level of this.route.pathFromRoot) {
const lang = level.snapshot.paramMap.get('lang');
if (lang && SUPPORTED_LANGS.has(lang)) {
return lang;
}
downloadItemFile(itemId: string, filename: string): void {
if (!this.selectedOrder) {
return;
}
return 'it';
this.adminOrdersService.downloadOrderItemFile(this.selectedOrder.id, itemId).subscribe({
next: (blob) => {
this.downloadBlob(blob, filename || `order-item-${itemId}`);
},
error: () => {
this.errorMessage = 'Download file non riuscito.';
}
});
}
downloadConfirmation(): void {
if (!this.selectedOrder) {
return;
}
this.adminOrdersService.downloadOrderConfirmation(this.selectedOrder.id).subscribe({
next: (blob) => {
this.downloadBlob(blob, `conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`);
},
error: () => {
this.errorMessage = 'Download conferma ordine non riuscito.';
}
});
}
downloadInvoice(): void {
if (!this.selectedOrder) {
return;
}
this.adminOrdersService.downloadOrderInvoice(this.selectedOrder.id).subscribe({
next: (blob) => {
this.downloadBlob(blob, `fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`);
},
error: () => {
this.errorMessage = 'Download fattura non riuscito.';
}
});
}
onStatusChange(event: Event): void {
const value = (event.target as HTMLSelectElement | null)?.value ?? '';
this.selectedStatus = value;
}
onPaymentMethodChange(event: Event): void {
const value = (event.target as HTMLSelectElement | null)?.value ?? 'OTHER';
this.selectedPaymentMethod = value;
}
openPrintDetails(): void {
this.showPrintDetails = true;
}
closePrintDetails(): void {
this.showPrintDetails = false;
}
getQualityLabel(layerHeight?: number): string {
if (!layerHeight || Number.isNaN(layerHeight)) {
return '-';
}
if (layerHeight <= 0.12) {
return 'Alta';
}
if (layerHeight <= 0.2) {
return 'Standard';
}
return 'Bozza';
}
isHexColor(value?: string): boolean {
return typeof value === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
}
isSelected(orderId: string): boolean {
return this.selectedOrder?.id === orderId;
}
private applyOrderUpdate(updatedOrder: AdminOrder): void {
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
this.refreshFilteredOrders();
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];
return;
}
this.filteredOrders = this.orders.filter((order) => {
const fullUuid = order.id.toLowerCase();
const shortUuid = (order.orderNumber || '').toLowerCase();
return fullUuid.includes(term) || shortUuid.includes(term);
});
}
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

@@ -0,0 +1,43 @@
<section class="section-card">
<header class="section-header">
<div>
<h2>Stock filamenti</h2>
<p>Monitoraggio quantità disponibili per variante.</p>
</div>
<button type="button" (click)="loadStock()" [disabled]="loading">Aggiorna</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<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>
</section>
<ng-template #loadingTpl>
<p>Caricamento stock...</p>
</ng-template>

View File

@@ -0,0 +1,77 @@
.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);
}
.badge {
display: inline-block;
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.78rem;
font-weight: 700;
}
.badge.low {
background: #ffebee;
color: var(--color-danger-500);
}
.badge.ok {
background: #e6f5ea;
color: #157347;
}
.error {
color: var(--color-danger-500);
}

View File

@@ -0,0 +1,41 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminFilamentStockRow, AdminOperationsService } from '../services/admin-operations.service';
@Component({
selector: 'app-admin-filament-stock',
standalone: true,
imports: [CommonModule],
templateUrl: './admin-filament-stock.component.html',
styleUrl: './admin-filament-stock.component.scss'
})
export class AdminFilamentStockComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
rows: AdminFilamentStockRow[] = [];
loading = false;
errorMessage: string | null = null;
ngOnInit(): void {
this.loadStock();
}
loadStock(): void {
this.loading = true;
this.errorMessage = null;
this.adminOperationsService.getFilamentStock().subscribe({
next: (rows) => {
this.rows = rows;
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare lo stock filamenti.';
}
});
}
isLowStock(row: AdminFilamentStockRow): boolean {
return Number(row.stockKg) < 1;
}
}

View File

@@ -1,27 +1,31 @@
<section class="admin-login-page">
<div class="admin-login-card">
<h1>Back-office</h1>
<p>Inserisci la password condivisa.</p>
<div class="container">
<section class="admin-login-page">
<div class="admin-login-card">
<h1>Back-office</h1>
<form (ngSubmit)="submit()">
<label for="admin-password">Password</label>
<input
id="admin-password"
name="password"
type="password"
[(ngModel)]="password"
[disabled]="loading"
autocomplete="current-password"
required
/>
<form (ngSubmit)="submit()">
<label for="admin-password">Password</label>
<input
id="admin-password"
name="password"
type="password"
[(ngModel)]="password"
[disabled]="loading || lockSecondsRemaining > 0"
autocomplete="current-password"
required
/>
<button type="submit" [disabled]="loading || !password.trim()">
{{ loading ? 'Accesso...' : 'Accedi' }}
</button>
</form>
<button type="submit" [disabled]="loading || !password.trim() || lockSecondsRemaining > 0">
{{ loading ? 'Accesso...' : 'Accedi' }}
</button>
</form>
@if (errorMessage) {
<p class="error">{{ errorMessage }}</p>
}
</div>
</section>
@if (errorMessage) {
<p class="error">{{ errorMessage }}</p>
}
@if (lockSecondsRemaining > 0) {
<p class="hint">Riprova tra {{ lockSecondsRemaining }}s.</p>
}
</div>
</section>
</div>

View File

@@ -3,33 +3,33 @@
justify-content: center;
align-items: center;
min-height: 70vh;
padding: 2rem 1rem;
padding: var(--space-8) 0;
}
.admin-login-card {
width: 100%;
max-width: 420px;
background: #fff;
border: 1px solid #d6dde8;
border-radius: 14px;
padding: 1.5rem;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
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);
}
h1 {
margin: 0;
font-size: 1.6rem;
font-size: 1.5rem;
}
p {
margin: 0.5rem 0 1.25rem;
color: #46546a;
margin: var(--space-2) 0 var(--space-5);
color: var(--color-text-muted);
}
form {
display: flex;
flex-direction: column;
gap: 0.65rem;
gap: var(--space-3);
}
label {
@@ -37,20 +37,25 @@ label {
}
input {
border: 1px solid #c3cedd;
border-radius: 10px;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
font-size: 1rem;
}
button {
border: 0;
border-radius: 10px;
background: #0f3f6f;
color: #fff;
padding: 0.75rem 0.9rem;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-neutral-900);
padding: var(--space-3) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
button:hover:not(:disabled) {
background: var(--color-brand-hover);
}
button:disabled {
@@ -59,6 +64,12 @@ button:disabled {
}
.error {
margin-top: 1rem;
color: #b0182a;
margin-top: var(--space-4);
margin-bottom: var(--space-2);
color: var(--color-danger-500);
}
.hint {
margin: 0;
color: var(--color-text-muted);
}

View File

@@ -1,8 +1,9 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { Component, inject, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminAuthService } from '../services/admin-auth.service';
import { HttpErrorResponse } from '@angular/common/http';
import { AdminAuthResponse, AdminAuthService } from '../services/admin-auth.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@@ -13,7 +14,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
templateUrl: './admin-login.component.html',
styleUrl: './admin-login.component.scss'
})
export class AdminLoginComponent {
export class AdminLoginComponent implements OnDestroy {
private readonly authService = inject(AdminAuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
@@ -21,9 +22,11 @@ export class AdminLoginComponent {
password = '';
loading = false;
errorMessage: string | null = null;
lockSecondsRemaining = 0;
private lockTimer: ReturnType<typeof setInterval> | null = null;
submit(): void {
if (!this.password.trim() || this.loading) {
if (!this.password.trim() || this.loading || this.lockSecondsRemaining > 0) {
return;
}
@@ -31,24 +34,25 @@ export class AdminLoginComponent {
this.errorMessage = null;
this.authService.login(this.password).subscribe({
next: (isAuthenticated) => {
next: (response: AdminAuthResponse) => {
this.loading = false;
if (!isAuthenticated) {
this.errorMessage = 'Password non valida.';
if (!response?.authenticated) {
this.handleLoginFailure(response?.retryAfterSeconds);
return;
}
this.clearLock();
const redirect = this.route.snapshot.queryParamMap.get('redirect');
if (redirect && redirect.startsWith('/')) {
void this.router.navigateByUrl(redirect);
return;
}
void this.router.navigate(['/', this.resolveLang(), 'admin']);
void this.router.navigate(['/', this.resolveLang(), 'admin', 'orders']);
},
error: () => {
error: (error: HttpErrorResponse) => {
this.loading = false;
this.errorMessage = 'Password non valida.';
this.handleLoginFailure(this.extractRetryAfterSeconds(error));
}
});
}
@@ -62,4 +66,59 @@ export class AdminLoginComponent {
}
return 'it';
}
private handleLoginFailure(retryAfterSeconds: number | undefined): void {
const timeout = this.normalizeTimeout(retryAfterSeconds);
this.errorMessage = 'Password non valida.';
this.startLock(timeout);
}
private extractRetryAfterSeconds(error: HttpErrorResponse): number {
const fromBody = Number(error?.error?.retryAfterSeconds);
if (Number.isFinite(fromBody) && fromBody > 0) {
return Math.floor(fromBody);
}
const fromHeader = Number(error?.headers?.get('Retry-After'));
if (Number.isFinite(fromHeader) && fromHeader > 0) {
return Math.floor(fromHeader);
}
return 2;
}
private normalizeTimeout(value: number | undefined): number {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return Math.floor(parsed);
}
return 2;
}
private startLock(seconds: number): void {
this.lockSecondsRemaining = Math.max(this.lockSecondsRemaining, seconds);
this.stopTimer();
this.lockTimer = setInterval(() => {
this.lockSecondsRemaining = Math.max(0, this.lockSecondsRemaining - 1);
if (this.lockSecondsRemaining === 0) {
this.stopTimer();
}
}, 1000);
}
private clearLock(): void {
this.lockSecondsRemaining = 0;
this.stopTimer();
}
private stopTimer(): void {
if (this.lockTimer !== null) {
clearInterval(this.lockTimer);
this.lockTimer = null;
}
}
ngOnDestroy(): void {
this.stopTimer();
}
}

View File

@@ -0,0 +1,39 @@
<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

@@ -0,0 +1,59 @@
.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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,40 @@
<section class="section-card">
<header class="section-header">
<div>
<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>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
<div class="table-wrap" *ngIf="!loading; else loadingTpl">
<table>
<thead>
<tr>
<th>Sessione</th>
<th>Data creazione</th>
<th>Scadenza</th>
<th>Materiale</th>
<th>Stato</th>
<th>Ordine convertito</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>
</tbody>
</table>
</div>
</section>
<ng-template #loadingTpl>
<p>Caricamento sessioni...</p>
</ng-template>

View File

@@ -0,0 +1,59 @@
.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

@@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AdminOperationsService, AdminQuoteSession } from '../services/admin-operations.service';
@Component({
selector: 'app-admin-sessions',
standalone: true,
imports: [CommonModule],
templateUrl: './admin-sessions.component.html',
styleUrl: './admin-sessions.component.scss'
})
export class AdminSessionsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
sessions: AdminQuoteSession[] = [];
loading = false;
errorMessage: string | null = null;
ngOnInit(): void {
this.loadSessions();
}
loadSessions(): void {
this.loading = true;
this.errorMessage = null;
this.adminOperationsService.getSessions().subscribe({
next: (sessions) => {
this.sessions = sessions;
this.loading = false;
},
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare le sessioni.';
}
});
}
}

View File

@@ -0,0 +1,24 @@
<div class="container admin-container">
<section class="admin-shell">
<aside class="sidebar">
<div class="brand">
<h1>Back-office</h1>
<p>Amministrazione operativa</p>
</div>
<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>
</nav>
<button type="button" class="logout" (click)="logout()">Logout</button>
</aside>
<main class="content">
<router-outlet></router-outlet>
</main>
</section>
</div>

View File

@@ -0,0 +1,120 @@
.admin-container {
margin-top: var(--space-8);
max-width: min(1720px, 96vw);
padding: 0 var(--space-6);
}
.admin-shell {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: calc(100vh - 220px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--color-bg-card);
box-shadow: var(--shadow-sm);
}
.sidebar {
background: var(--color-neutral-100);
color: var(--color-text);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
border-right: 1px solid var(--color-border);
}
.brand h1 {
margin: 0;
font-size: 1.15rem;
}
.brand p {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.menu {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.menu a {
text-decoration: none;
color: var(--color-text-muted);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-weight: 600;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.menu a:hover {
border-color: var(--color-brand);
color: var(--color-text);
}
.menu a.active {
background: #fff5b8;
color: var(--color-text);
border-color: var(--color-brand);
}
.logout {
margin-top: auto;
border: 1px solid var(--color-border);
color: var(--color-text);
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.logout:hover {
border-color: var(--color-brand);
background: #fff8cc;
}
.content {
background: var(--color-bg);
padding: var(--space-6);
min-width: 0;
}
@media (max-width: 960px) {
.admin-container {
margin-top: var(--space-6);
padding: 0 var(--space-4);
}
.admin-shell {
grid-template-columns: 1fr;
min-height: unset;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--color-border);
padding: var(--space-4);
}
.menu {
flex-direction: row;
flex-wrap: wrap;
}
.logout {
margin-top: var(--space-2);
align-self: flex-start;
}
.content {
padding: var(--space-4);
}
}

View File

@@ -0,0 +1,40 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AdminAuthService } from '../services/admin-auth.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@Component({
selector: 'app-admin-shell',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './admin-shell.component.html',
styleUrl: './admin-shell.component.scss'
})
export class AdminShellComponent {
private readonly adminAuthService = inject(AdminAuthService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
logout(): void {
this.adminAuthService.logout().subscribe({
next: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
},
error: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
}
});
}
private resolveLang(): string {
for (const level of this.route.pathFromRoot) {
const lang = level.snapshot.paramMap.get('lang');
if (lang && SUPPORTED_LANGS.has(lang)) {
return lang;
}
}
return 'it';
}
}

View File

@@ -1,10 +1,13 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { environment } from '../../../../environments/environment';
interface AdminAuthResponse {
export interface AdminAuthResponse {
authenticated: boolean;
retryAfterSeconds?: number;
expiresInMinutes?: number;
}
@Injectable({
@@ -14,10 +17,8 @@ export class AdminAuthService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`;
login(password: string): Observable<boolean> {
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true }).pipe(
map((response) => Boolean(response?.authenticated))
);
login(password: string): Observable<AdminAuthResponse> {
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true });
}
logout(): Observable<void> {

View File

@@ -0,0 +1,56 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../../environments/environment';
export interface AdminFilamentStockRow {
filamentVariantId: number;
materialCode: string;
variantDisplayName: string;
colorName: string;
stockSpools: number;
spoolNetKg: number;
stockKg: number;
active: boolean;
}
export interface AdminContactRequest {
id: string;
requestType: string;
customerType: string;
email: string;
phone?: string;
name?: string;
companyName?: string;
status: string;
createdAt: string;
}
export interface AdminQuoteSession {
id: string;
status: string;
materialCode: string;
createdAt: string;
expiresAt: string;
convertedOrderId?: string;
}
@Injectable({
providedIn: 'root'
})
export class AdminOperationsService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin`;
getFilamentStock(): Observable<AdminFilamentStockRow[]> {
return this.http.get<AdminFilamentStockRow[]>(`${this.baseUrl}/filament-stock`, { withCredentials: true });
}
getContactRequests(): Observable<AdminContactRequest[]> {
return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true });
}
getSessions(): Observable<AdminQuoteSession[]> {
return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, { withCredentials: true });
}
}

View File

@@ -24,9 +24,19 @@ export interface AdminOrder {
customerEmail: string;
totalChf: number;
createdAt: string;
printMaterialCode?: string;
printNozzleDiameterMm?: number;
printLayerHeightMm?: number;
printInfillPattern?: string;
printInfillPercent?: number;
printSupportsEnabled?: boolean;
items: AdminOrderItem[];
}
export interface AdminUpdateOrderStatusPayload {
status: string;
}
@Injectable({
providedIn: 'root'
})
@@ -42,7 +52,32 @@ export class AdminOrdersService {
return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, { withCredentials: true });
}
confirmPayment(orderId: string): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/payments/confirm`, {}, { withCredentials: true });
confirmPayment(orderId: string, method: string): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/payments/confirm`, { method }, { withCredentials: true });
}
updateOrderStatus(orderId: string, payload: AdminUpdateOrderStatusPayload): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/status`, payload, { withCredentials: true });
}
downloadOrderItemFile(orderId: string, orderItemId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/items/${orderItemId}/file`, {
withCredentials: true,
responseType: 'blob'
});
}
downloadOrderConfirmation(orderId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, {
withCredentials: true,
responseType: 'blob'
});
}
downloadOrderInvoice(orderId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, {
withCredentials: true,
responseType: 'blob'
});
}
}