feat(back-end): shop ui implementation
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 19s
PR Checks / security-sast (pull_request) Successful in 34s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 59s

This commit is contained in:
2026-03-10 08:31:29 +01:00
parent cd0c13203f
commit a212a1d8cc
32 changed files with 4233 additions and 396 deletions

View File

@@ -67,12 +67,26 @@
</option>
</select>
</label>
<label class="toolbar-field" for="order-type-filter">
<span>Tipo ordine</span>
<select
class="ui-form-control"
id="order-type-filter"
[ngModel]="orderTypeFilter"
(ngModelChange)="onOrderTypeFilterChange($event)"
>
<option *ngFor="let option of orderTypeFilterOptions" [ngValue]="option">
{{ option }}
</option>
</select>
</label>
</div>
<div class="table-wrap ui-table-wrap">
<table class="ui-data-table">
<thead>
<tr>
<th>Ordine</th>
<th>Tipo</th>
<th>Email</th>
<th>Pagamento</th>
<th>Stato ordine</th>
@@ -86,6 +100,15 @@
(click)="openDetails(order.id)"
>
<td>{{ order.orderNumber }}</td>
<td>
<span
class="order-type-badge"
[class.order-type-badge--shop]="orderKind(order) === 'SHOP'"
[class.order-type-badge--mixed]="orderKind(order) === 'MIXED'"
>
{{ orderKindLabel(order) }}
</span>
</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.paymentStatus || "PENDING" }}</td>
<td>{{ order.status }}</td>
@@ -94,7 +117,7 @@
</td>
</tr>
<tr class="no-results" *ngIf="filteredOrders.length === 0">
<td colspan="5">
<td colspan="6">
Nessun ordine trovato per i filtri selezionati.
</td>
</tr>
@@ -105,7 +128,16 @@
<section class="detail-panel ui-detail-panel" *ngIf="selectedOrder">
<div class="detail-header">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<div class="detail-title-row">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<span
class="order-type-badge"
[class.order-type-badge--shop]="orderKind(selectedOrder) === 'SHOP'"
[class.order-type-badge--mixed]="orderKind(selectedOrder) === 'MIXED'"
>
{{ orderKindLabel(selectedOrder) }}
</span>
</div>
<p class="order-uuid">
UUID:
<code
@@ -129,6 +161,9 @@
<div class="ui-meta-item">
<strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span>
</div>
<div class="ui-meta-item">
<strong>Tipo ordine</strong><span>{{ orderKindLabel(selectedOrder) }}</span>
</div>
<div class="ui-meta-item">
<strong>Totale</strong
><span>{{
@@ -207,6 +242,7 @@
type="button"
class="ui-button ui-button--ghost"
(click)="openPrintDetails()"
[disabled]="!hasPrintItems(selectedOrder)"
>
Dettagli stampa
</button>
@@ -215,38 +251,60 @@
<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 }} | Materiale:
{{ getItemMaterialLabel(item) }} | Colore:
<div class="item-heading">
<p class="file-name">
<strong>{{ itemDisplayName(item) }}</strong>
</p>
<span
class="color-swatch"
*ngIf="getItemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>
{{ getItemColorLabel(item) }}
<ng-container *ngIf="getItemColorCodeSuffix(item) as colorCode">
({{ colorCode }})
</ng-container>
class="item-kind-badge"
[class.item-kind-badge--shop]="isShopItem(item)"
>
{{ isShopItem(item) ? "Shop" : "Calcolatore" }}
</span>
| Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
</div>
<p class="item-meta">
<span>Qta: {{ item.quantity }}</span>
<span *ngIf="showItemMaterial(item)">
Materiale: {{ getItemMaterialLabel(item) }}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
Variante: {{ variantLabel }}
</span>
<span class="item-meta__color">
Colore:
<span
class="color-swatch"
*ngIf="getItemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>
{{ getItemColorLabel(item) }}
<ng-container *ngIf="getItemColorCodeSuffix(item) as colorCode">
({{ colorCode }})
</ng-container>
</span>
</span>
</p>
<p class="item-tech" *ngIf="showItemPrintDetails(item)">
Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
{{ item.layerHeightMm ?? "-" }} mm | Infill:
{{ item.infillPercent ?? "-" }}% | Supporti:
{{ formatSupports(item.supportsEnabled) }}
| Riga:
</p>
<p class="item-total">
Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</p>
</div>
<button
type="button"
class="ui-button ui-button--ghost"
(click)="downloadItemFile(item.id, item.originalFilename)"
>
Scarica file
</button>
<div class="item-actions">
<button
type="button"
class="ui-button ui-button--ghost"
(click)="downloadItemFile(item.id, item.originalFilename || itemDisplayName(item))"
>
{{ downloadItemLabel(item) }}
</button>
</div>
</div>
</div>
</section>
@@ -315,7 +373,7 @@
<h4>Parametri per file</h4>
<div class="file-color-list">
<div class="file-color-row" *ngFor="let item of selectedOrder.items">
<div class="file-color-row" *ngFor="let item of printItems(selectedOrder)">
<span class="filename">{{ item.originalFilename }}</span>
<span class="file-color">
{{ getItemMaterialLabel(item) }} | Colore:

View File

@@ -21,10 +21,11 @@
.list-toolbar {
display: grid;
grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(
190px,
1fr
);
grid-template-columns:
minmax(230px, 1.6fr)
minmax(170px, 1fr)
minmax(190px, 1fr)
minmax(170px, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
@@ -69,6 +70,13 @@ tbody tr.no-results:hover {
margin: 0 0 var(--space-2);
}
.detail-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.actions-block {
display: flex;
flex-wrap: wrap;
@@ -113,6 +121,15 @@ tbody tr.no-results:hover {
.item-main {
min-width: 0;
display: grid;
gap: var(--space-2);
}
.item-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.file-name {
@@ -124,7 +141,7 @@ tbody tr.no-results:hover {
}
.item-meta {
margin: var(--space-1) 0 0;
margin: 0;
font-size: 0.84rem;
color: var(--color-text-muted);
display: flex;
@@ -133,7 +150,33 @@ tbody tr.no-results:hover {
flex-wrap: wrap;
}
.item button {
.item-meta__color {
display: inline-flex;
align-items: center;
gap: 6px;
}
.item-tech,
.item-total {
margin: 0;
font-size: 0.84rem;
}
.item-tech {
color: var(--color-text-muted);
}
.item-total {
font-weight: 600;
color: var(--color-text);
}
.item-actions {
display: flex;
align-items: flex-start;
}
.item-actions button {
justify-self: start;
}
@@ -150,6 +193,32 @@ tbody tr.no-results:hover {
margin: 0;
}
.order-type-badge,
.item-kind-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.2rem 0.65rem;
background: var(--color-neutral-100);
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
white-space: nowrap;
}
.order-type-badge--shop,
.item-kind-badge--shop {
background: color-mix(in srgb, var(--color-brand) 12%, white);
color: var(--color-brand);
}
.order-type-badge--mixed {
background: color-mix(in srgb, #f59e0b 16%, white);
color: #9a5b00;
}
.modal-backdrop {
position: fixed;
inset: 0;
@@ -247,6 +316,10 @@ h4 {
align-items: flex-start;
}
.item-actions {
width: 100%;
}
.actions-block {
flex-direction: column;
align-items: stretch;

View File

@@ -26,6 +26,7 @@ export class AdminDashboardComponent implements OnInit {
orderSearchTerm = '';
paymentStatusFilter = 'ALL';
orderStatusFilter = 'ALL';
orderTypeFilter = 'ALL';
showPrintDetails = false;
loading = false;
detailLoading = false;
@@ -62,6 +63,7 @@ export class AdminDashboardComponent implements OnInit {
'COMPLETED',
'CANCELLED',
];
readonly orderTypeFilterOptions = ['ALL', 'SHOP', 'CALCULATOR', 'MIXED'];
ngOnInit(): void {
this.loadOrders();
@@ -117,6 +119,11 @@ export class AdminDashboardComponent implements OnInit {
this.applyListFiltersAndSelection();
}
onOrderTypeFilterChange(value: string): void {
this.orderTypeFilter = value || 'ALL';
this.applyListFiltersAndSelection();
}
openDetails(orderId: string): void {
this.detailLoading = true;
this.adminOrdersService.getOrder(orderId).subscribe({
@@ -124,6 +131,7 @@ export class AdminDashboardComponent implements OnInit {
this.selectedOrder = order;
this.selectedStatus = order.status;
this.selectedPaymentMethod = order.paymentMethod || 'OTHER';
this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(order);
this.detailLoading = false;
},
error: () => {
@@ -247,6 +255,9 @@ export class AdminDashboardComponent implements OnInit {
}
openPrintDetails(): void {
if (!this.selectedOrder || !this.hasPrintItems(this.selectedOrder)) {
return;
}
this.showPrintDetails = true;
}
@@ -267,6 +278,34 @@ export class AdminDashboardComponent implements OnInit {
return 'Bozza';
}
isShopItem(item: AdminOrderItem): boolean {
return String(item?.itemType ?? '').toUpperCase() === 'SHOP_PRODUCT';
}
itemDisplayName(item: AdminOrderItem): string {
const displayName = (item.displayName || '').trim();
if (displayName) {
return displayName;
}
const shopName = (item.shopProductName || '').trim();
if (shopName) {
return shopName;
}
return (item.originalFilename || '').trim() || '-';
}
itemVariantLabel(item: AdminOrderItem): string | null {
const variantLabel = (item.shopVariantLabel || '').trim();
if (variantLabel) {
return variantLabel;
}
const colorName = (item.shopVariantColorName || '').trim();
return colorName || null;
}
isHexColor(value?: string): boolean {
return (
typeof value === 'string' &&
@@ -291,12 +330,22 @@ export class AdminDashboardComponent implements OnInit {
}
getItemColorLabel(item: AdminOrderItem): string {
const shopColorName = (item.shopVariantColorName || '').trim();
if (shopColorName) {
return shopColorName;
}
const colorName = (item.filamentColorName || '').trim();
const colorCode = (item.colorCode || '').trim();
return colorName || colorCode || '-';
}
getItemColorHex(item: AdminOrderItem): string | null {
const shopColorHex = (item.shopVariantColorHex || '').trim();
if (this.isHexColor(shopColorHex)) {
return shopColorHex;
}
const variantHex = (item.filamentColorHex || '').trim();
if (this.isHexColor(variantHex)) {
return variantHex;
@@ -336,6 +385,54 @@ export class AdminDashboardComponent implements OnInit {
return 'Supporti -';
}
showItemMaterial(item: AdminOrderItem): boolean {
return !this.isShopItem(item);
}
showItemPrintDetails(item: AdminOrderItem): boolean {
return !this.isShopItem(item);
}
hasShopItems(order: AdminOrder | null): boolean {
return (order?.items || []).some((item) => this.isShopItem(item));
}
hasPrintItems(order: AdminOrder | null): boolean {
return (order?.items || []).some((item) => !this.isShopItem(item));
}
printItems(order: AdminOrder | null): AdminOrderItem[] {
return (order?.items || []).filter((item) => !this.isShopItem(item));
}
orderKind(order: AdminOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' {
const hasShop = this.hasShopItems(order);
const hasPrint = this.hasPrintItems(order);
if (hasShop && hasPrint) {
return 'MIXED';
}
if (hasShop) {
return 'SHOP';
}
return 'CALCULATOR';
}
orderKindLabel(order: AdminOrder | null): string {
switch (this.orderKind(order)) {
case 'SHOP':
return 'Shop';
case 'MIXED':
return 'Misto';
default:
return 'Calcolatore';
}
}
downloadItemLabel(item: AdminOrderItem): string {
return this.isShopItem(item) ? 'Scarica modello' : 'Scarica file';
}
isSelected(orderId: string): boolean {
return this.selectedOrder?.id === orderId;
}
@@ -349,6 +446,7 @@ export class AdminDashboardComponent implements OnInit {
this.selectedStatus = updatedOrder.status;
this.selectedPaymentMethod =
updatedOrder.paymentMethod || this.selectedPaymentMethod;
this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(updatedOrder);
}
private applyListFiltersAndSelection(): void {
@@ -384,8 +482,16 @@ export class AdminDashboardComponent implements OnInit {
const matchesOrderStatus =
this.orderStatusFilter === 'ALL' ||
orderStatus === this.orderStatusFilter;
const matchesOrderType =
this.orderTypeFilter === 'ALL' ||
this.orderKind(order) === this.orderTypeFilter;
return matchesSearch && matchesPayment && matchesOrderStatus;
return (
matchesSearch &&
matchesPayment &&
matchesOrderStatus &&
matchesOrderType
);
});
}

View File

@@ -5,10 +5,19 @@ import { environment } from '../../../../environments/environment';
export interface AdminOrderItem {
id: string;
itemType: string;
originalFilename: string;
displayName?: string;
materialCode: string;
colorCode: string;
filamentVariantId?: number;
shopProductId?: string;
shopProductVariantId?: string;
shopProductSlug?: string;
shopProductName?: string;
shopVariantLabel?: string;
shopVariantColorName?: string;
shopVariantColorHex?: string;
filamentVariantDisplayName?: string;
filamentColorName?: string;
filamentColorHex?: string;