produzione 1 #9
@@ -1,60 +0,0 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/dev/email")
|
||||
@Profile("local")
|
||||
public class DevEmailTestController {
|
||||
|
||||
private final TemplateEngine templateEngine;
|
||||
|
||||
public DevEmailTestController(TemplateEngine templateEngine) {
|
||||
this.templateEngine = templateEngine;
|
||||
}
|
||||
|
||||
@GetMapping("/test-template")
|
||||
public ResponseEntity<String> testTemplate() {
|
||||
Context context = new Context();
|
||||
Map<String, Object> templateData = new HashMap<>();
|
||||
UUID orderId = UUID.randomUUID();
|
||||
templateData.put("customerName", "Mario Rossi");
|
||||
templateData.put("orderId", orderId);
|
||||
templateData.put("orderNumber", orderId.toString().split("-")[0]);
|
||||
templateData.put("orderDetailsUrl", "https://tuosito.it/it/co/" + orderId);
|
||||
templateData.put("orderDate", OffsetDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
|
||||
templateData.put("totalCost", "CHF 45.50");
|
||||
templateData.put("currentYear", OffsetDateTime.now().getYear());
|
||||
templateData.put("emailTitle", "Conferma ordine");
|
||||
templateData.put("headlineText", "Grazie per il tuo ordine #" + templateData.get("orderNumber"));
|
||||
templateData.put("greetingText", "Ciao Mario,");
|
||||
templateData.put("introText", "Abbiamo ricevuto il tuo ordine e iniziato l'elaborazione.");
|
||||
templateData.put("detailsTitleText", "Dettagli ordine");
|
||||
templateData.put("labelOrderNumber", "Numero ordine");
|
||||
templateData.put("labelDate", "Data");
|
||||
templateData.put("labelTotal", "Totale");
|
||||
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
|
||||
templateData.put("attachmentHintText", "In allegato trovi la conferma ordine in PDF con QR bill.");
|
||||
templateData.put("supportText", "Se hai domande, rispondi a questa email.");
|
||||
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
|
||||
|
||||
context.setVariables(templateData);
|
||||
String html = templateEngine.process("email/order-confirmation", context);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(html);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.printcalculator.dto.AdminContactRequestAttachmentDto;
|
||||
import com.printcalculator.dto.AdminContactRequestDetailDto;
|
||||
import com.printcalculator.dto.AdminFilamentStockDto;
|
||||
import com.printcalculator.dto.AdminQuoteSessionDto;
|
||||
import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest;
|
||||
import com.printcalculator.entity.CustomQuoteRequest;
|
||||
import com.printcalculator.entity.CustomQuoteRequestAttachment;
|
||||
import com.printcalculator.entity.FilamentVariant;
|
||||
@@ -28,7 +29,9 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -41,15 +44,18 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.CONFLICT;
|
||||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
@@ -60,6 +66,9 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
public class AdminOperationsController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.class);
|
||||
private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
|
||||
private static final Set<String> CONTACT_REQUEST_ALLOWED_STATUSES = Set.of(
|
||||
"NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED"
|
||||
);
|
||||
|
||||
private final FilamentVariantStockKgRepository filamentStockRepo;
|
||||
private final FilamentVariantRepository filamentVariantRepo;
|
||||
@@ -156,22 +165,40 @@ public class AdminOperationsController {
|
||||
.map(this::toContactRequestAttachmentDto)
|
||||
.toList();
|
||||
|
||||
AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto();
|
||||
dto.setId(request.getId());
|
||||
dto.setRequestType(request.getRequestType());
|
||||
dto.setCustomerType(request.getCustomerType());
|
||||
dto.setEmail(request.getEmail());
|
||||
dto.setPhone(request.getPhone());
|
||||
dto.setName(request.getName());
|
||||
dto.setCompanyName(request.getCompanyName());
|
||||
dto.setContactPerson(request.getContactPerson());
|
||||
dto.setMessage(request.getMessage());
|
||||
dto.setStatus(request.getStatus());
|
||||
dto.setCreatedAt(request.getCreatedAt());
|
||||
dto.setUpdatedAt(request.getUpdatedAt());
|
||||
dto.setAttachments(attachments);
|
||||
return ResponseEntity.ok(toContactRequestDetailDto(request, attachments));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(dto);
|
||||
@PatchMapping("/contact-requests/{requestId}/status")
|
||||
@Transactional
|
||||
public ResponseEntity<AdminContactRequestDetailDto> updateContactRequestStatus(
|
||||
@PathVariable UUID requestId,
|
||||
@RequestBody AdminUpdateContactRequestStatusRequest payload
|
||||
) {
|
||||
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
|
||||
|
||||
String requestedStatus = payload != null && payload.getStatus() != null
|
||||
? payload.getStatus().trim().toUpperCase(Locale.ROOT)
|
||||
: "";
|
||||
|
||||
if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) {
|
||||
throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES)
|
||||
);
|
||||
}
|
||||
|
||||
request.setStatus(requestedStatus);
|
||||
request.setUpdatedAt(OffsetDateTime.now());
|
||||
CustomQuoteRequest saved = customQuoteRequestRepo.save(request);
|
||||
|
||||
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
|
||||
.findByRequest_IdOrderByCreatedAtAsc(requestId)
|
||||
.stream()
|
||||
.map(this::toContactRequestAttachmentDto)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(toContactRequestDetailDto(saved, attachments));
|
||||
}
|
||||
|
||||
@GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file")
|
||||
@@ -291,6 +318,27 @@ public class AdminOperationsController {
|
||||
return dto;
|
||||
}
|
||||
|
||||
private AdminContactRequestDetailDto toContactRequestDetailDto(
|
||||
CustomQuoteRequest request,
|
||||
List<AdminContactRequestAttachmentDto> attachments
|
||||
) {
|
||||
AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto();
|
||||
dto.setId(request.getId());
|
||||
dto.setRequestType(request.getRequestType());
|
||||
dto.setCustomerType(request.getCustomerType());
|
||||
dto.setEmail(request.getEmail());
|
||||
dto.setPhone(request.getPhone());
|
||||
dto.setName(request.getName());
|
||||
dto.setCompanyName(request.getCompanyName());
|
||||
dto.setContactPerson(request.getContactPerson());
|
||||
dto.setMessage(request.getMessage());
|
||||
dto.setStatus(request.getStatus());
|
||||
dto.setCreatedAt(request.getCreatedAt());
|
||||
dto.setUpdatedAt(request.getUpdatedAt());
|
||||
dto.setAttachments(attachments);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) {
|
||||
AdminQuoteSessionDto dto = new AdminQuoteSessionDto();
|
||||
dto.setId(session.getId());
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
public class AdminUpdateContactRequestStatusRequest {
|
||||
private String status;
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ FRONTEND_PORT=18082
|
||||
CLAMAV_HOST=192.168.1.147
|
||||
CLAMAV_PORT=3310
|
||||
CLAMAV_ENABLED=true
|
||||
APP_FRONTEND_BASE_URL=http://localhost:18082
|
||||
APP_FRONTEND_BASE_URL=https://dev.3d-fab.ch
|
||||
ADMIN_PASSWORD=
|
||||
ADMIN_SESSION_SECRET=
|
||||
ADMIN_SESSION_TTL_MINUTES=480
|
||||
|
||||
@@ -10,7 +10,7 @@ FRONTEND_PORT=18081
|
||||
CLAMAV_HOST=192.168.1.147
|
||||
CLAMAV_PORT=3310
|
||||
CLAMAV_ENABLED=true
|
||||
APP_FRONTEND_BASE_URL=http://localhost:18081
|
||||
APP_FRONTEND_BASE_URL=https://int.3d-fab.ch
|
||||
ADMIN_PASSWORD=
|
||||
ADMIN_SESSION_SECRET=
|
||||
ADMIN_SESSION_TTL_MINUTES=480
|
||||
|
||||
@@ -4,8 +4,8 @@ ENV=prod
|
||||
TAG=prod
|
||||
|
||||
# Ports
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=80
|
||||
BACKEND_PORT=18000
|
||||
FRONTEND_PORT=18080
|
||||
|
||||
CLAMAV_HOST=192.168.1.147
|
||||
CLAMAV_PORT=3310
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</header>
|
||||
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
<p class="success" *ngIf="successMessage">{{ successMessage }}</p>
|
||||
|
||||
<div class="workspace" *ngIf="!loading; else loadingTpl">
|
||||
<section class="list-panel">
|
||||
@@ -80,6 +81,24 @@
|
||||
<div class="meta-item"><dt>Referente</dt><dd>{{ selectedRequest.contactPerson || '-' }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="status-editor">
|
||||
<div class="status-editor-field">
|
||||
<label for="contact-request-status">Stato richiesta</label>
|
||||
<select
|
||||
id="contact-request-status"
|
||||
[ngModel]="selectedStatus"
|
||||
(ngModelChange)="selectedStatus = $event">
|
||||
<option *ngFor="let status of statusOptions" [ngValue]="status">{{ status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="updateRequestStatus()"
|
||||
[disabled]="!selectedRequest || updatingStatus || !selectedStatus || selectedStatus === selectedRequest.status">
|
||||
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="message-box">
|
||||
<h4>Messaggio</h4>
|
||||
<p>{{ selectedRequest.message || '-' }}</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
padding: clamp(12px, 2vw, 24px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@@ -44,11 +44,16 @@
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(500px, 1.25fr) minmax(420px, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.list-panel,
|
||||
.detail-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -87,6 +92,7 @@ button.ghost {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
thead {
|
||||
@@ -308,6 +314,43 @@ tbody tr.selected {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #157347;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.status-editor {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-neutral-100);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.status-editor-field {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.status-editor-field label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-editor-field select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-bg-card);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -356,9 +399,9 @@ button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 1060px) {
|
||||
@media (min-width: 1460px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.9fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,4 +428,28 @@ button:disabled {
|
||||
align-items: flex-start;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.request-id code {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.status-editor {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-editor button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.section-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
AdminContactRequest,
|
||||
AdminContactRequestAttachment,
|
||||
@@ -10,19 +11,23 @@ import {
|
||||
@Component({
|
||||
selector: 'app-admin-contact-requests',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-contact-requests.component.html',
|
||||
styleUrl: './admin-contact-requests.component.scss'
|
||||
})
|
||||
export class AdminContactRequestsComponent implements OnInit {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
|
||||
readonly statusOptions = ['NEW', 'PENDING', 'IN_PROGRESS', 'DONE', 'CLOSED'];
|
||||
requests: AdminContactRequest[] = [];
|
||||
selectedRequest: AdminContactRequestDetail | null = null;
|
||||
selectedRequestId: string | null = null;
|
||||
loading = false;
|
||||
detailLoading = false;
|
||||
updatingStatus = false;
|
||||
selectedStatus = '';
|
||||
errorMessage: string | null = null;
|
||||
successMessage: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRequests();
|
||||
@@ -31,6 +36,7 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
loadRequests(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.adminOperationsService.getContactRequests().subscribe({
|
||||
next: (requests) => {
|
||||
this.requests = requests;
|
||||
@@ -54,9 +60,11 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
openDetails(requestId: string): void {
|
||||
this.selectedRequestId = requestId;
|
||||
this.detailLoading = true;
|
||||
this.errorMessage = null;
|
||||
this.adminOperationsService.getContactRequestDetail(requestId).subscribe({
|
||||
next: (detail) => {
|
||||
this.selectedRequest = detail;
|
||||
this.selectedStatus = detail.status || '';
|
||||
this.detailLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
@@ -111,6 +119,37 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
return 'chip-light';
|
||||
}
|
||||
|
||||
updateRequestStatus(): void {
|
||||
if (!this.selectedRequest || !this.selectedRequestId || !this.selectedStatus || this.updatingStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = null;
|
||||
this.successMessage = null;
|
||||
this.updatingStatus = true;
|
||||
|
||||
this.adminOperationsService.updateContactRequestStatus(this.selectedRequestId, { status: this.selectedStatus }).subscribe({
|
||||
next: (updated) => {
|
||||
this.selectedRequest = updated;
|
||||
this.selectedStatus = updated.status || this.selectedStatus;
|
||||
this.requests = this.requests.map(request =>
|
||||
request.id === updated.id
|
||||
? {
|
||||
...request,
|
||||
status: updated.status
|
||||
}
|
||||
: request
|
||||
);
|
||||
this.updatingStatus = false;
|
||||
this.successMessage = 'Stato richiesta aggiornato.';
|
||||
},
|
||||
error: () => {
|
||||
this.updatingStatus = false;
|
||||
this.errorMessage = 'Impossibile aggiornare lo stato della richiesta.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
padding: clamp(12px, 2vw, 20px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@@ -31,11 +31,16 @@
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(540px, 1.35fr) minmax(420px, 0.95fr);
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.9fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.list-panel,
|
||||
.detail-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -107,6 +112,7 @@ button:disabled {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
thead {
|
||||
@@ -218,7 +224,7 @@ tbody tr.no-results:hover {
|
||||
}
|
||||
|
||||
.status-editor select {
|
||||
min-width: 220px;
|
||||
min-width: 210px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -383,13 +389,21 @@ h4 {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1060px) {
|
||||
@media (max-width: 1280px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.admin-dashboard {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -406,4 +420,43 @@ h4 {
|
||||
.item {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.actions-block {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-editor select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-actions button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.items {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.admin-dashboard {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
padding: clamp(12px, 2vw, 24px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@ select:disabled {
|
||||
.variant-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -346,6 +347,7 @@ button:disabled {
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -355,8 +357,56 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.form-grid {
|
||||
@media (max-width: 900px) {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.material-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.section-card {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.variant-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.variant-head-main {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.variant-head-actions {
|
||||
width: 100%;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.expand-toggle {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.panel button,
|
||||
.subpanel button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.section-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 70vh;
|
||||
padding: var(--space-8) 0;
|
||||
padding: var(--space-8) var(--space-3);
|
||||
}
|
||||
|
||||
.admin-login-card {
|
||||
@@ -73,3 +73,18 @@ button:disabled {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.admin-login-page {
|
||||
min-height: 64vh;
|
||||
padding: var(--space-5) var(--space-2);
|
||||
}
|
||||
|
||||
.admin-login-card {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
padding: clamp(12px, 2vw, 24px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ button {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 920px;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -112,6 +113,7 @@ td {
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 620px;
|
||||
}
|
||||
|
||||
.detail-table th,
|
||||
@@ -124,3 +126,39 @@ td {
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.detail-box {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
gap: var(--space-2);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.section-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
<p>Amministrazione operativa</p>
|
||||
</div>
|
||||
|
||||
<nav class="menu">
|
||||
<a routerLink="orders" routerLinkActive="active">Ordini</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>
|
||||
<div class="menu-scroll">
|
||||
<nav class="menu">
|
||||
<a routerLink="orders" routerLinkActive="active">Ordini</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>
|
||||
</div>
|
||||
|
||||
<button type="button" class="logout" (click)="logout()">Logout</button>
|
||||
</aside>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.admin-container {
|
||||
margin-top: var(--space-8);
|
||||
max-width: min(1720px, 96vw);
|
||||
padding: 0 var(--space-6);
|
||||
padding: 0 clamp(12px, 2.2vw, 24px);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
@@ -42,6 +42,10 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.menu-scroll {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu a {
|
||||
text-decoration: none;
|
||||
color: var(--color-text-muted);
|
||||
@@ -51,6 +55,7 @@
|
||||
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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu a:hover {
|
||||
@@ -87,10 +92,20 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1240px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.admin-container {
|
||||
margin-top: var(--space-6);
|
||||
padding: 0 var(--space-4);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
@@ -104,17 +119,39 @@
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.menu-scroll {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
margin: 0 calc(-1 * var(--space-1));
|
||||
padding-inline: var(--space-1);
|
||||
}
|
||||
|
||||
.menu {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.logout {
|
||||
margin-top: var(--space-2);
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-1);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.brand h1 {
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,10 @@ export interface AdminContactRequestDetail {
|
||||
attachments: AdminContactRequestAttachment[];
|
||||
}
|
||||
|
||||
export interface AdminUpdateContactRequestStatusPayload {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminQuoteSession {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -187,6 +191,17 @@ export class AdminOperationsService {
|
||||
return this.http.get<AdminContactRequestDetail>(`${this.baseUrl}/contact-requests/${requestId}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
updateContactRequestStatus(
|
||||
requestId: string,
|
||||
payload: AdminUpdateContactRequestStatusPayload
|
||||
): Observable<AdminContactRequestDetail> {
|
||||
return this.http.patch<AdminContactRequestDetail>(
|
||||
`${this.baseUrl}/contact-requests/${requestId}/status`,
|
||||
payload,
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
downloadContactRequestAttachment(requestId: string, attachmentId: string): Observable<Blob> {
|
||||
return this.http.get(`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`, {
|
||||
withCredentials: true,
|
||||
|
||||
Reference in New Issue
Block a user