feat(back-end and front-end): back-office
This commit is contained in:
@@ -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)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
120
frontend/src/app/features/admin/pages/admin-shell.component.scss
Normal file
120
frontend/src/app/features/admin/pages/admin-shell.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user