Compare commits

...

4 Commits

30 changed files with 5916 additions and 0 deletions

View File

@@ -12,6 +12,8 @@ Il software si chiama Zentral e, tramite diverse applicazioni, si occupa di gest
- magazzino (Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte)
- HR (o personale) (Gestione personale, contratti, pagamenti, assenze, rimborsi e analisi personale)
- report e stampe (Gestione report, creazione e analisi report)
- comunicazioni (Gestione invio mail, chat interna, condivisione risorse del gestionale ad interni ed esterni)
- corsi e formazione (Gestione corsi di formazione, erogazione corsi, tracciabilità scadenze)
mostra statistiche grafiche per ogni applicazione nella dashboard dell'applicazione.

View File

@@ -49,3 +49,7 @@ File riassuntivo dello stato di sviluppo di Zentral.
- Corretta chiave di traduzione errata per la tab "Gestione Applicazioni" e migliorata la gestione dell'aggiornamento etichette tab.
- [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato**
- Riorganizzazione UI Auto Codes, allineamento stile a Custom Fields, miglioramento traduzioni e categorizzazione.
- [2025-12-12 - Modulo Comunicazioni](./devlog/2025-12-12-110000_communications_module.md) - **In Corso**
- Implementazione invio email e gestione comunicazioni.
- [2025-12-12 - Gestione Modulo Formazione (Generale)](./devlog/2025-12-12-105500_safety_training_schedule.md) - **In Corso**
- Implementazione modulo formazione generale e scadenziario.

View File

@@ -0,0 +1,78 @@
# Implementazione Modulo Formazione (Generale)
## Obiettivo
Creare un modulo generale per la gestione della formazione (Training), permettendo all'utente di definire corsi di diverso tipo (es. Sicurezza, Tecnici, Qualità, Soft Skills) in base alle esigenze del business. Il sistema gestirà scadenze, attestati e partecipanti in modo agnostico rispetto al tipo di corso.
## Strategia
Mapping delle funzionalità sui moduli esistenti:
1. **Anagrafica Corsi** -> Modulo **Magazzino** (`Articolo`)
- La radice della Categoria Merceologica sarà "Formazione".
- Le sottocategorie definiranno il tipo di corso (es. "Sicurezza", "IT").
2. **Anagrafica Soggetti** -> Modulo **Clienti** (`Cliente` + nuova entità `ClienteContatto`)
3. **Gestione Attestati e Scadenze** -> Nuovo Modulo **Training** (Formazione)
4. **Workflow Notifiche** -> Human-in-the-loop tramite Dashboard dedicato.
## Piano di Lavoro
### 1. Documentazione e Analisi
- [ ] Creazione piano di lavoro (questo file).
- [ ] Aggiornamento `ZENTRAL.md`.
### 2. Backend (.NET)
#### Domain Layer
- [ ] **Refactoring Categorie (Warehouse)**:
- Implementare gestione **Gruppi Merceologici a 3 livelli** (Standardizzazione Classificazione).
- Utilizzare la categoria "Formazione" come root per identificare i corsi.
- [ ] **Modifica Entity `Articolo`**:
- Aggiungere gestione **Validità/Scadenza Standard** (es. `int? GiorniValidita`).
- Il campo sarà utilizzato per calcolare la data di scadenza del corso una volta erogato.
- [ ] **Nuova Entity `ClienteContatto`**:
- Proprietà: `Nome`, `Cognome`, `Email`, `Ruolo`, `Telefono`, foreign key a `Cliente`.
- Aggiornare `Cliente` con collection `Contatti`.
- [ ] **Nuova Entity `TrainingRecord`**:
- Rappresenta l'avvenuta formazione per un contatto.
- Proprietà: `ClienteContattoId`, `ArticoloId` (Corso), `DataEsecuzione`, `DataScadenza` (Calcolata), `AttestatoUrl`, `Stato` (Valid, Expiring, Expired), `Note`.
- Entità generica per qualsiasi tipo di corso.
#### Infrastructure / EF Core
- [ ] Creare Migrazione EF per le nuove entità e modifiche.
- [ ] Aggiornare `ApplicationDbContext`.
#### API Layer
- [ ] **Aggiornare `ArticoliController`**: Gestione nuovi campi (Validità, Categorie).
- [ ] **Gestione Classificazioni**: Implementare API per gestire la gerarchia (o livelli) delle categorie merceologiche.
- [ ] **Aggiornare `ClientiController`**: Gestione CRUD Contatti.
- [ ] **Nuovo `TrainingController`**:
- CRUD TrainingRecords.
- Upload file attestato.
- Endpoint `GetExpiringTrainings` per la dashboard (filtri per data, azienda, categoria corso).
- Endpoint `approve-notification`: Invio email notifiche scadenze.
### 3. Frontend (React)
#### Modulo Training (Nuova App `training`)
- [ ] **Setup Modulo**: Creare cartella `src/frontend/src/apps/training` e configurare route.
- [ ] **Componenti**:
- `TrainingDashboard`: Widget con scadenze imminenti e scadute, grafici per tipologia corso.
- `CourseRegistry`: Tabella corsi (Articoli filtrati per categoria "Formazione"). Permette di creare nuovi corsi e gestire le sottocategorie (Tipi di corso).
- `TrainingMatrix`: Vista partecipanti x corsi o lista formazioni.
- `TrainingForm`: Modale inserimento/modifica formazione (Caricamento file, calcolo automatico scadenza basato sul corso).
#### Integrazione Moduli Esistenti
- [ ] **Magazzino**: Gestione UI per Classificazioni a 3 livelli (Gruppo/Famiglia).
- [ ] **Magazzino**: Aggiungere campi Validità/Scadenza nel form Articolo.
- [ ] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti.
### 4. Workflow e Notifiche
- [ ] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard.
- [ ] Integrazione con il Modulo Email per invio solleciti scadenze.
### 5. Verifica e Test
- [ ] Test flusso completo:
1. Creazione "Tipo Corso" (Sottocategoria).
2. Creazione Corso con validità.
3. Creazione Contatto.
4. Registrazione Formazione.
5. Verifica Scadenza e Notifica.
## Stato Attuale
- Inizio analisi e setup.

View File

@@ -0,0 +1,51 @@
# Implementazione Modulo Comunicazioni (Ex Email Standard)
## Obiettivo
Implementare il modulo **Comunicazioni** (`communications`), inizialmente focalizzato sulla gestione centralizzata dell'invio email (SMTP).
Questo modulo servirà da fondamento per tutte le comunicazioni in uscita (e in futuro interne) del gestionale.
## Strategia
Il modulo gestirà sia l'infrastruttura tecnica (Service Layer per invio mail) sia l'interfaccia utente per la configurazione e il monitoraggio (Log).
Sarà allineato alla visione del modulo "Comunicazioni" (Gestione invio mail, chat interna, ecc.).
## Piano di Lavoro
### 1. Documentazione
- [x] Aggiornamento piano di lavoro (questo file).
- [x] Aggiornamento `ZENTRAL.md`.
### 2. Backend (.NET)
#### Domain Layer (`Zentral.Domain`)
- [x] **Interfaccia `IEmailSender`**: Contratto standard per l'invio.
- [x] **Entities (Namespace `Communications`)**:
- `EmailLog`: Storico invii (`Id`, `Data`, `Mittente`, `Destinatario`, `Oggetto`, `Stato`, `Errore`).
- `EmailTemplate` (Opzionale Fase 1): Per standardizzare il layout delle mail.
#### Infrastructure Layer (`Zentral.Infrastructure`)
- [x] **Implementazione `SmtpEmailSender`**:
- Logica di invio tramite MailKit.
- Integrazione con `Configurazione` per leggere le credenziali SMTP a runtime.
- Salvataggio automatico del log in `EmailLog`.
#### API Layer (`Zentral.API`)
- [x] **Controller `CommunicationsController`**:
- Endpoint per test invio.
- Endpoint per consultazione Logs.
- Endpoint per salvataggio Configurazione SMTP.
### 3. Frontend (React)
#### Modulo `communications` (`src/apps/communications`)
- [x] **Setup App**: Creazione struttura standard modulo.
- [x] **Settings Page**:
- Form per configurazione SMTP (Host, Port, User, Pass, SSL).
- Pulsante "Test Connessione".
- [x] **Logs Page**:
- Tabella visualizzazione storico email inviate con stato (Successo/Errore).
## Integrazione
- Il servizio `IEmailSender` sarà iniettato negli altri moduli (es. Safety) per l'invio delle notifiche.
## Verifica
- [ ] Configurazione SMTP (es. Mailtrap).
- [ ] Test invio mail da interfaccia.
- [ ] Verifica scrittura Log su DB.

View File

@@ -0,0 +1,29 @@
# Implementazione Configurazione Email in Amministrazione
## Obiettivo
Rendere disponibile la configurazione dell'invio email del modulo Comunicazioni nella sezione Amministrazione dell'interfaccia grafica.
## Stato Attuale
- Il backend ha già gli endpoint per la configurazione SMTP (`api/communications/config`).
- Esiste già una pagina `SettingsPage` nel modulo Comunicazioni (`src/frontend/src/apps/communications/pages/SettingsPage.tsx`) che gestisce il form di configurazione.
- Il modulo Comunicazioni non è attualmente visibile nel menu principale se non attivo/acquistato, ma la configurazione email è un setting globale che dovrebbe essere accessibile.
## Piano di Lavoro
1. **Aggiornamento Route**: Aggiungere una route `/admin/email-config` in `App.tsx` che punta alla pagina di configurazione esistente (o un wrapper).
2. **Aggiornamento Menu**: Aggiungere la voce "Configurazione Email" nel menu "Amministrazione" in `Sidebar.tsx`.
3. **Traduzioni**: Aggiungere le chiavi di traduzione per la nuova voce di menu in `it/translation.json` e `en/translation.json`.
4. **Test**: Avviare l'applicazione e verificare che la pagina sia accessibile e funzionante.
## Dettagli Tecnici
- Riutilizzare `src/frontend/src/apps/communications/pages/SettingsPage.tsx`.
- La route sarà protetta se necessario, ma accessibile come parte dell'amministrazione.
## Stato Finale
- [x] Aggiunta route `/admin/email-config` in `App.tsx`.
- [x] Aggiunta voce menu "Configurazione Email" in `Sidebar.tsx`.
- [x] Aggiunte traduzioni IT ed EN.
- [x] Installato .NET 9.0 SDK via script locale (`~/.dotnet`).
- [x] Installato `dotnet-ef` tool.
- [x] Creata migrazione `UpdateCommunicationsModule` e aggiornato il database.
- [x] Backend avviato su porta 5000.
- [x] Frontend avviato su porta 5173.

View File

@@ -0,0 +1,37 @@
# Integrazione Supporto Resend per Invio Email
## Obiettivo
Abilitare l'invio di email tramite servizi terzi (Resend) oltre al già presente SMTP, con configurazione via interfaccia grafica.
## Stato Attuale
- Backend: `SmtpEmailSender` gestisce solo SMTP.
- Frontend: `SettingsPage` gestisce solo campi SMTP.
- DTO: `SmtpConfigDto` limitato a SMTP.
## Piano di Lavoro
1. **Backend DTO**: Aggiornare `SmtpConfigDto` con campi `Provider` e `ResendApiKey`.
2. **Backend Controller**: Aggiornare `CommunicationsController` per leggere/salvare le nuove configurazioni (`EMAIL_PROVIDER`, `RESEND_API_KEY`).
3. **Backend Service**: Modificare `SmtpEmailSender` (o rinominarlo in `UnifiedEmailSender`) per supportare la logica condizionale (SMTP vs Resend). Implementare l'invio tramite HTTP Client per Resend.
4. **Frontend Service**: Aggiornare le definizioni di tipo TypeScript.
5. **Frontend UI**: Modificare `SettingsPage` per aggiungere un selettore di provider (SMTP/Resend) e mostrare i campi pertinenti dinamicamente.
6. **Traduzioni**: Aggiungere le nuove etichette.
## Dettagli Tecnici
- **API Resend**: Richiesta POST a `https://api.resend.com/emails` con Bearer Token.
- **Provider Enum**: "smtp", "resend".
- **Defaut**: SMTP per retrocompatibilità.
## Avanzamento
- [x] Backend DTO Update (`SmtpConfigDto`)
- [x] Backend Controller Update (`CommunicationsController`)
- [x] Backend Service Logic (`SmtpEmailSender` now handles Resend via HTTP)
- [x] Frontend Types Update
- [x] Frontend UI Update (`SettingsPage.tsx` with Provider selector)
- [x] Dependencies (Added `Microsoft.Extensions.Http` to Infrastructure)
## Note Finali
- L'integrazione supporta ora la selezione dinamica tra SMTP e Resend.
- La configurazione viene salvata su database (`EMAIL_PROVIDER`, `RESEND_API_KEY`).
- Il backend utilizza `IHttpClientFactory` per le chiamate API verso Resend.
- UI aggiornata per mostrare campi condizionali.

View File

@@ -0,0 +1,117 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Zentral.API.Apps.Communications.Dtos;
using Zentral.Domain.Entities;
using Zentral.Domain.Interfaces;
using Zentral.Infrastructure.Data;
using System.Security.Claims;
namespace Zentral.API.Apps.Communications.Controllers;
[ApiController]
[Route("api/communications")]
public class CommunicationsController : ControllerBase
{
private readonly ZentralDbContext _context;
private readonly IEmailSender _emailSender;
public CommunicationsController(ZentralDbContext context, IEmailSender emailSender)
{
_context = context;
_emailSender = emailSender;
}
[HttpGet("config")]
public async Task<ActionResult<SmtpConfigDto>> GetConfig()
{
var configs = await _context.Configurazioni
.Where(c => c.Chiave.StartsWith("SMTP_") || c.Chiave == "EMAIL_PROVIDER" || c.Chiave == "RESEND_API_KEY")
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
var dto = new SmtpConfigDto
{
Host = GetValue(configs, "SMTP_HOST"),
Port = int.Parse(GetValue(configs, "SMTP_PORT", "587")),
User = GetValue(configs, "SMTP_USER"),
Password = GetValue(configs, "SMTP_PASS"),
EnableSsl = bool.Parse(GetValue(configs, "SMTP_SSL", "false")),
FromEmail = GetValue(configs, "SMTP_FROM_EMAIL"),
FromName = GetValue(configs, "SMTP_FROM_NAME"),
Provider = GetValue(configs, "EMAIL_PROVIDER", "smtp"),
ResendApiKey = GetValue(configs, "RESEND_API_KEY")
};
return Ok(dto);
}
[HttpPost("config")]
public async Task<ActionResult> SaveConfig(SmtpConfigDto dto)
{
await SetConfig("SMTP_HOST", dto.Host);
await SetConfig("SMTP_PORT", dto.Port.ToString());
await SetConfig("SMTP_USER", dto.User);
await SetConfig("SMTP_PASS", dto.Password);
await SetConfig("SMTP_SSL", dto.EnableSsl.ToString().ToLower());
await SetConfig("SMTP_FROM_EMAIL", dto.FromEmail);
await SetConfig("SMTP_FROM_NAME", dto.FromName);
await SetConfig("EMAIL_PROVIDER", dto.Provider);
await SetConfig("RESEND_API_KEY", dto.ResendApiKey);
await _context.SaveChangesAsync();
return Ok();
}
[HttpPost("send-test")]
public async Task<ActionResult> SendTestEmail(TestEmailDto dto)
{
try
{
await _emailSender.SendEmailAsync(dto.To, dto.Subject, dto.Body);
return Ok(new { message = "Email send process initiated. Check logs for status." });
}
catch (Exception ex)
{
return BadRequest(new { message = ex.Message });
}
}
[HttpGet("logs")]
public async Task<ActionResult<List<EmailLogDto>>> GetLogs([FromQuery] int limit = 50)
{
var logs = await _context.EmailLogs
.OrderByDescending(l => l.SentDate)
.Take(limit)
.Select(l => new EmailLogDto
{
Id = l.Id,
SentDate = l.SentDate,
Sender = l.Sender,
Recipient = l.Recipient,
Subject = l.Subject,
Status = l.Status,
ErrorMessage = l.ErrorMessage
})
.ToListAsync();
return Ok(logs);
}
private string GetValue(Dictionary<string, string?> dict, string key, string def = "")
{
return dict.ContainsKey(key) && dict[key] != null ? dict[key]! : def;
}
private async Task SetConfig(string key, string? value)
{
var config = await _context.Configurazioni.FirstOrDefaultAsync(c => c.Chiave == key);
if (config == null)
{
config = new Configurazione { Chiave = key, CreatedAt = DateTime.UtcNow, CreatedBy = User.FindFirstValue(ClaimTypes.Name) ?? "System" };
_context.Configurazioni.Add(config);
}
config.Valore = value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = User.FindFirstValue(ClaimTypes.Name) ?? "System";
}
}

View File

@@ -0,0 +1,14 @@
using System;
namespace Zentral.API.Apps.Communications.Dtos;
public class EmailLogDto
{
public int Id { get; set; }
public DateTime SentDate { get; set; }
public string Sender { get; set; } = string.Empty;
public string Recipient { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Zentral.API.Apps.Communications.Dtos;
public class SmtpConfigDto
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public string User { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public bool EnableSsl { get; set; } = false;
public string FromEmail { get; set; } = string.Empty;
public string FromName { get; set; } = string.Empty;
// New fields for Resend support
public string Provider { get; set; } = "smtp"; // "smtp" or "resend"
public string ResendApiKey { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace Zentral.API.Apps.Communications.Dtos;
public class TestEmailDto
{
public string To { get; set; } = string.Empty;
public string Subject { get; set; } = "Test Email from Zentral";
public string Body { get; set; } = "This is a test email sent from Zentral Communications Module.";
}

View File

@@ -6,7 +6,10 @@ using Zentral.API.Apps.Warehouse.Services;
using Zentral.API.Apps.Purchases.Services;
using Zentral.API.Apps.Sales.Services;
using Zentral.API.Apps.Production.Services;
using Zentral.API.Apps.Production.Services;
using Zentral.Infrastructure.Data;
using Zentral.Infrastructure.Services;
using Zentral.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization;
@@ -19,6 +22,7 @@ builder.Services.AddDbContext<ZentralDbContext>(options =>
options.UseSqlite(connectionString));
// Services
builder.Services.AddHttpClient();
builder.Services.AddScoped<EventoCostiService>();
builder.Services.AddScoped<DemoDataService>();
builder.Services.AddScoped<ReportGeneratorService>();
@@ -28,6 +32,9 @@ builder.Services.AddScoped<AutoCodeService>();
builder.Services.AddScoped<CustomFieldService>();
builder.Services.AddSingleton<DataNotificationService>();
// Communications Module Services
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
// Warehouse Module Services
builder.Services.AddScoped<IWarehouseService, WarehouseService>();

View File

@@ -521,6 +521,20 @@ public class AppService
RoutePath = "/report-designer",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new App
{
Code = "communications",
Name = "Comunicazioni",
Description = "Gestione invio mail, chat interna e condivisione risorse",
Icon = "Email",
BasePrice = 1000m,
MonthlyMultiplier = 1.2m,
SortOrder = 90,
IsCore = false,
RoutePath = "/communications",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
}
};

View File

@@ -0,0 +1,14 @@
using System;
using Zentral.Domain;
namespace Zentral.Domain.Entities.Communications;
public class EmailLog : BaseEntity
{
public DateTime SentDate { get; set; }
public string Sender { get; set; } = string.Empty;
public string Recipient { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty; // "Success", "Failed"
public string? ErrorMessage { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Zentral.Domain.Interfaces;
public interface IEmailSender
{
Task SendEmailAsync(string to, string subject, string body, bool isHtml = true);
Task SendEmailAsync(string to, string subject, string body, List<string> attachments, bool isHtml = true);
}

View File

@@ -4,6 +4,7 @@ using Zentral.Domain.Entities.Purchases;
using Zentral.Domain.Entities.Sales;
using Zentral.Domain.Entities.Production;
using Zentral.Domain.Entities.HR;
using Zentral.Domain.Entities.Communications;
using Microsoft.EntityFrameworkCore;
namespace Zentral.Infrastructure.Data;
@@ -95,6 +96,9 @@ public class ZentralDbContext : DbContext
public DbSet<Pagamento> Pagamenti => Set<Pagamento>();
public DbSet<Rimborso> Rimborsi => Set<Rimborso>();
// Communications module entities
public DbSet<EmailLog> EmailLogs => Set<EmailLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -989,5 +993,16 @@ public class ZentralDbContext : DbContext
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Cascade);
});
// ===============================================
// COMMUNICATIONS MODULE ENTITIES
// ===============================================
modelBuilder.Entity<EmailLog>(entity =>
{
entity.ToTable("EmailLogs");
entity.HasIndex(e => e.SentDate);
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.Recipient);
});
}
}

View File

@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Zentral.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UpdateCommunicationsModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EmailLogs",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SentDate = table.Column<DateTime>(type: "TEXT", nullable: false),
Sender = table.Column<string>(type: "TEXT", nullable: false),
Recipient = table.Column<string>(type: "TEXT", nullable: false),
Subject = table.Column<string>(type: "TEXT", nullable: false),
Status = table.Column<string>(type: "TEXT", nullable: false),
ErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EmailLogs", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_EmailLogs_Recipient",
table: "EmailLogs",
column: "Recipient");
migrationBuilder.CreateIndex(
name: "IX_EmailLogs_SentDate",
table: "EmailLogs",
column: "SentDate");
migrationBuilder.CreateIndex(
name: "IX_EmailLogs_Status",
table: "EmailLogs",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmailLogs");
}
}
}

View File

@@ -418,6 +418,60 @@ namespace Zentral.Infrastructure.Migrations
b.ToTable("CodiciCategoria");
});
modelBuilder.Entity("Zentral.Domain.Entities.Communications.EmailLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT");
b.Property<string>("CustomFieldsJson")
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.HasColumnType("TEXT");
b.Property<string>("Recipient")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Sender")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("SentDate")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UpdatedBy")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Recipient");
b.HasIndex("SentDate");
b.HasIndex("Status");
b.ToTable("EmailLogs", (string)null);
});
modelBuilder.Entity("Zentral.Domain.Entities.Configurazione", b =>
{
b.Property<int>("Id")

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MimeKit;
using MailKit.Net.Smtp;
using MailKit.Security;
using System.Net.Http.Json;
using System.Text.Json;
using System.Net.Http;
using Zentral.Domain.Entities.Communications;
using Zentral.Domain.Interfaces;
using Zentral.Infrastructure.Data;
namespace Zentral.Infrastructure.Services;
public class SmtpEmailSender : IEmailSender
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpClientFactory;
public SmtpEmailSender(IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory)
{
_scopeFactory = scopeFactory;
_httpClientFactory = httpClientFactory;
}
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true)
{
await SendEmailAsync(to, subject, body, new List<string>(), isHtml);
}
public async Task SendEmailAsync(string to, string subject, string body, List<string> attachments, bool isHtml = true)
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ZentralDbContext>();
// 1. Get Configuration
var configs = await context.Configurazioni
.Where(c => c.Chiave.StartsWith("SMTP_") || c.Chiave == "EMAIL_PROVIDER" || c.Chiave == "RESEND_API_KEY")
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
var provider = GetConfig(configs, "EMAIL_PROVIDER", "smtp");
if (provider.ToLower() == "resend")
{
await SendViaResendAsync(context, to, subject, body, attachments, isHtml, configs);
}
else
{
await SendViaSmtpAsync(context, to, subject, body, attachments, isHtml, configs);
}
}
private async Task SendViaResendAsync(ZentralDbContext context, string to, string subject, string body, List<string> attachments, bool isHtml, Dictionary<string, string?> configs)
{
var apiKey = GetConfig(configs, "RESEND_API_KEY");
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL"); // Resend often requires a verified domain, but we reuse the field
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
if (string.IsNullOrEmpty(apiKey))
{
await LogResultAsync(context, fromEmail, to, subject, "Failed", "Resend API Key not configured");
return;
}
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
var request = new
{
from = $"{fromName} <{fromEmail}>",
to = new[] { to },
subject = subject,
html = isHtml ? body : null,
text = !isHtml ? body : null,
attachments = attachments.Select(a => {
var bytes = System.IO.File.ReadAllBytes(a);
return new
{
filename = System.IO.Path.GetFileName(a),
content = Convert.ToBase64String(bytes)
};
}).ToArray()
};
var response = await client.PostAsJsonAsync("https://api.resend.com/emails", request);
if (response.IsSuccessStatusCode)
{
await LogResultAsync(context, fromEmail, to, subject, "Success", "Via Resend");
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
await LogResultAsync(context, fromEmail, to, subject, "Failed", $"Resend Error: {errorContent}");
}
}
catch (Exception ex)
{
await LogResultAsync(context, fromEmail, to, subject, "Failed", ex.Message);
}
}
private async Task SendViaSmtpAsync(ZentralDbContext context, string to, string subject, string body, List<string> attachments, bool isHtml, Dictionary<string, string?> configs)
{
var host = GetConfig(configs, "SMTP_HOST");
var portStr = GetConfig(configs, "SMTP_PORT", "587");
var user = GetConfig(configs, "SMTP_USER");
var pass = GetConfig(configs, "SMTP_PASS");
var sslStr = GetConfig(configs, "SMTP_SSL", "false");
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL", user);
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
if (string.IsNullOrEmpty(host))
{
await LogResultAsync(context, fromEmail, to, subject, "Failed", "SMTP Host not configured");
return;
}
int.TryParse(portStr, out int port);
bool.TryParse(sslStr, out bool useSsl);
// 2. Prepare Message
var message = new MimeMessage();
message.From.Add(new MailboxAddress(fromName, fromEmail));
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
var builder = new BodyBuilder();
if (isHtml)
builder.HtmlBody = body;
else
builder.TextBody = body;
foreach (var attachment in attachments)
{
if (System.IO.File.Exists(attachment))
{
builder.Attachments.Add(attachment);
}
}
message.Body = builder.ToMessageBody();
// 3. Send
try
{
using var client = new SmtpClient();
if (port == 465)
await client.ConnectAsync(host, port, SecureSocketOptions.SslOnConnect);
else if (port == 587)
await client.ConnectAsync(host, port, SecureSocketOptions.StartTls);
else
await client.ConnectAsync(host, port, SecureSocketOptions.Auto);
if (!string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(pass))
{
await client.AuthenticateAsync(user, pass);
}
await client.SendAsync(message);
await client.DisconnectAsync(true);
await LogResultAsync(context, fromEmail, to, subject, "Success", null);
}
catch (Exception ex)
{
await LogResultAsync(context, fromEmail, to, subject, "Failed", ex.Message);
}
}
private string GetConfig(Dictionary<string, string?> configs, string key, string defaultValue = "")
{
return configs.ContainsKey(key) && !string.IsNullOrEmpty(configs[key]) ? configs[key]! : defaultValue;
}
private async Task LogResultAsync(ZentralDbContext context, string from, string to, string subject, string status, string? error)
{
var log = new EmailLog
{
SentDate = DateTime.UtcNow,
Sender = from,
Recipient = to,
Subject = subject,
Status = status,
ErrorMessage = error,
CreatedAt = DateTime.UtcNow,
CreatedBy = "System"
};
context.EmailLogs.Add(log);
await context.SaveChangesAsync();
}
}

View File

@@ -10,6 +10,8 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="MailKit" Version="4.3.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
</ItemGroup>
<PropertyGroup>

View File

@@ -62,6 +62,7 @@
"cycles": "Cycles",
"mrp": "MRP",
"administration": "Administration",
"emailConfig": "Email Configuration",
"movements": "Movements",
"stock": "Stock",
"inventory": "Inventory"

View File

@@ -58,6 +58,7 @@
"cycles": "Cicli",
"mrp": "MRP",
"administration": "Amministrazione",
"emailConfig": "Configurazione Email",
"movements": "Movimenti",
"stock": "Giacenze",
"inventory": "Inventario"

View File

@@ -19,11 +19,13 @@ import SalesRoutes from "./apps/sales/routes";
import ProductionRoutes from "./apps/production/routes";
import EventsRoutes from "./apps/events/routes";
import HRRoutes from "./apps/hr/routes";
import CommunicationsRoutes from "./apps/communications/routes";
import { AppGuard } from "./components/AppGuard";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext";
import { AppProvider } from "./contexts/AppContext";
import { TabProvider } from "./contexts/TabContext";
import EmailConfigPage from "./apps/communications/pages/SettingsPage";
const queryClient = new QueryClient({
defaultOptions: {
@@ -81,6 +83,10 @@ function App() {
path="admin/custom-fields"
element={<CustomFieldsAdminPage />}
/>
<Route
path="admin/email-config"
element={<EmailConfigPage />}
/>
{/* Warehouse Module */}
<Route
path="warehouse/*"
@@ -135,6 +141,15 @@ function App() {
</AppGuard>
}
/>
{/* Communications Module */}
<Route
path="communications/*"
element={
<AppGuard appCode="communications">
<CommunicationsRoutes />
</AppGuard>
}
/>
</Route>
</Routes>
</TabProvider>

View File

@@ -0,0 +1,36 @@
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Box, Paper, Tab, Tabs } from "@mui/material";
export default function CommunicationsLayout() {
const navigate = useNavigate();
const location = useLocation();
const getActiveTab = () => {
const path = location.pathname;
if (path.includes("/communications/logs")) return "/communications/logs";
return "/communications/settings";
};
const handleChange = (_event: React.SyntheticEvent, newValue: string) => {
navigate(newValue);
};
return (
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Paper sx={{ mb: 2 }}>
<Tabs
value={getActiveTab()}
onChange={handleChange}
indicatorColor="primary"
textColor="primary"
>
<Tab label="Configurazione" value="/communications/settings" />
<Tab label="Logs" value="/communications/logs" />
</Tabs>
</Paper>
<Box sx={{ flex: 1, overflow: "auto" }}>
<Outlet />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Box, Typography } from '@mui/material';
import { History } from '@mui/icons-material';
import { communicationsService } from '../services/communicationsService';
import { EmailLog } from '../types';
import dayjs from 'dayjs';
export default function LogsPage() {
const [logs, setLogs] = useState<EmailLog[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
loadLogs();
}, []);
const loadLogs = async () => {
setLoading(true);
try {
const data = await communicationsService.getLogs(100);
setLogs(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const columns: GridColDef[] = [
{ field: 'id', headerName: 'ID', width: 70 },
{
field: 'sentDate', headerName: 'Data', width: 180,
valueFormatter: (params) => dayjs(params.value).format('DD/MM/YYYY HH:mm')
},
{
field: 'status', headerName: 'Stato', width: 120,
renderCell: (params) => (
<span style={{
color: params.value === 'Success' ? 'green' : 'red',
fontWeight: 'bold'
}}>
{params.value}
</span>
)
},
{ field: 'sender', headerName: 'Mittente', width: 200 },
{ field: 'recipient', headerName: 'Destinatario', width: 200 },
{ field: 'subject', headerName: 'Oggetto', flex: 1 },
{ field: 'errorMessage', headerName: 'Errore', width: 200 },
];
return (
<Box p={3} sx={{ height: '80vh', display: 'flex', flexDirection: 'column' }}>
<Box display="flex" justifyContent="space-between" mb={2}>
<Typography variant="h4"><History /> Email Logs</Typography>
</Box>
<DataGrid
rows={logs}
columns={columns}
loading={loading}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
}}
pageSizeOptions={[25, 50, 100]}
disableRowSelectionOnClick
/>
</Box>
);
}

View File

@@ -0,0 +1,241 @@
import React, { useEffect, useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
Box, Paper, Typography, TextField, Button, Grid,
Switch, FormControlLabel, Divider, Alert, Snackbar,
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import { Save, Send, Email } from '@mui/icons-material';
import { communicationsService } from '../services/communicationsService';
import { SmtpConfig, TestEmail } from '../types';
export default function SettingsPage() {
const { control, handleSubmit, reset, watch } = useForm<SmtpConfig>();
const provider = watch('provider') || 'smtp';
const [loading, setLoading] = useState(false);
const [testMode, setTestMode] = useState(false);
const [testData, setTestData] = useState<TestEmail>({ to: '', subject: 'Test Email', body: 'Test content' });
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setLoading(true);
const config = await communicationsService.getConfig();
reset(config);
} catch (error) {
console.error(error);
setNotification({ type: 'error', message: 'Failed to load configuration' });
} finally {
setLoading(false);
}
};
const onSubmit = async (data: SmtpConfig) => {
try {
setLoading(true);
await communicationsService.saveConfig(data);
setNotification({ type: 'success', message: 'Configuration saved successfully' });
} catch (error) {
setNotification({ type: 'error', message: 'Failed to save configuration' });
} finally {
setLoading(false);
}
};
const sendTest = async () => {
if (!testData.to) {
setNotification({ type: 'error', message: 'Recipient email is required for test' });
return;
}
try {
setLoading(true);
await communicationsService.sendTestEmail(testData);
setNotification({ type: 'success', message: 'Test email queued successfully' });
setTestMode(false);
} catch (error: any) {
setNotification({ type: 'error', message: error.response?.data?.message || 'Failed to send test email' });
} finally {
setLoading(false);
}
};
return (
<Box p={3}>
<Typography variant="h4" gutterBottom display="flex" alignItems="center" gap={2}>
<Email fontSize="large" color="primary" /> Configurazione Email
</Typography>
<Paper sx={{ p: 3, mb: 3 }}>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel>Provider</InputLabel>
<Controller
name="provider"
control={control}
defaultValue="smtp"
render={({ field }) => (
<Select {...field} label="Provider">
<MenuItem value="smtp">SMTP</MenuItem>
<MenuItem value="resend">Resend</MenuItem>
</Select>
)}
/>
</FormControl>
</Grid>
{provider === 'smtp' && (
<>
<Grid item xs={12} md={8}>
<Controller
name="host"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="SMTP Host" fullWidth required />}
/>
</Grid>
<Grid item xs={12} md={4}>
<Controller
name="port"
control={control}
defaultValue={587}
render={({ field }) => <TextField {...field} label="Port" type="number" fullWidth required />}
/>
</Grid>
<Grid item xs={12} md={6}>
<Controller
name="user"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="Username" fullWidth />}
/>
</Grid>
<Grid item xs={12} md={6}>
<Controller
name="password"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="Password" type="password" fullWidth />}
/>
</Grid>
<Grid item xs={12} md={4}>
<Controller
name="enableSsl"
control={control}
defaultValue={false}
render={({ field: { onChange, value } }) => (
<FormControlLabel
control={<Switch checked={value} onChange={onChange} />}
label="Enable SSL/TLS"
/>
)}
/>
</Grid>
</>
)}
{provider === 'resend' && (
<Grid item xs={12}>
<Controller
name="resendApiKey"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="Resend API Key" type="password" fullWidth required />}
/>
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}>
Ottieni la tua API Key su <a href="https://resend.com/api-keys" target="_blank" rel="noopener noreferrer">resend.com</a>
</Typography>
</Grid>
)}
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
<Typography variant="h6">Mittente Default</Typography>
</Grid>
<Grid item xs={12} md={6}>
<Controller
name="fromEmail"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="From Email" fullWidth required />}
/>
</Grid>
<Grid item xs={12} md={6}>
<Controller
name="fromName"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="From Name" fullWidth />}
/>
</Grid>
<Grid item xs={12} display="flex" justifyContent="space-between" alignItems="center">
<Button
variant="outlined"
startIcon={<Send />}
onClick={() => setTestMode(!testMode)}
>
Test Connessione
</Button>
<Button
type="submit"
variant="contained"
startIcon={<Save />}
disabled={loading}
>
Salva Configurazione
</Button>
</Grid>
</Grid>
</form>
</Paper>
{testMode && (
<Paper sx={{ p: 3, bgcolor: '#f5f5f5' }}>
<Typography variant="h6" gutterBottom>Test Email</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
label="Destinatario"
fullWidth
value={testData.to}
onChange={(e) => setTestData({ ...testData, to: e.target.value })}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
label="Oggetto"
fullWidth
value={testData.subject}
onChange={(e) => setTestData({ ...testData, subject: e.target.value })}
/>
</Grid>
<Grid item xs={12}>
<Button variant="contained" color="secondary" onClick={sendTest} disabled={loading}>
Invia Test
</Button>
</Grid>
</Grid>
</Paper>
)}
<Snackbar
open={!!notification}
autoHideDuration={6000}
onClose={() => setNotification(null)}
>
<Alert severity={notification?.type || 'info'} onClose={() => setNotification(null)}>
{notification?.message}
</Alert>
</Snackbar>
</Box>
);
}

View File

@@ -0,0 +1,16 @@
import { Routes, Route, Navigate } from "react-router-dom";
import SettingsPage from "./pages/SettingsPage";
import LogsPage from "./pages/LogsPage";
import CommunicationsLayout from "./components/CommunicationsLayout";
export default function CommunicationsRoutes() {
return (
<Routes>
<Route element={<CommunicationsLayout />}>
<Route index element={<Navigate to="settings" replace />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="logs" element={<LogsPage />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,22 @@
import api from '../../../services/api';
import { SmtpConfig, TestEmail, EmailLog } from '../types';
export const communicationsService = {
getConfig: async () => {
const response = await api.get<SmtpConfig>('/communications/config');
return response.data;
},
saveConfig: async (config: SmtpConfig) => {
await api.post('/communications/config', config);
},
sendTestEmail: async (data: TestEmail) => {
await api.post('/communications/send-test', data);
},
getLogs: async (limit: number = 50) => {
const response = await api.get<EmailLog[]>('/communications/logs', { params: { limit } });
return response.data;
}
};

View File

@@ -0,0 +1,27 @@
export interface SmtpConfig {
host: string;
port: number;
user: string;
password?: string;
enableSsl: boolean;
fromEmail: string;
fromName: string;
provider?: 'smtp' | 'resend';
resendApiKey?: string;
}
export interface TestEmail {
to: string;
subject: string;
body: string;
}
export interface EmailLog {
id: number;
sentDate: string;
sender: string;
recipient: string;
subject: string;
status: string;
errorMessage?: string;
}

View File

@@ -40,6 +40,7 @@ import {
Receipt as ReceiptIcon,
ChevronLeft,
ChevronRight,
Email as EmailIcon,
} from '@mui/icons-material';
import { useLocation } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
@@ -193,6 +194,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
{ id: 'autocodes', label: t('menu.autoCodes'), icon: <AutoCodeIcon />, path: '/admin/auto-codes', translationKey: 'menu.autoCodes' },
{ id: 'customfields', label: t('menu.customFields'), icon: <AutoCodeIcon />, path: '/admin/custom-fields', translationKey: 'menu.customFields' },
{ id: 'reports', label: t('menu.reports'), icon: <PrintIcon />, path: '/report-designer', appCode: 'report-designer', translationKey: 'menu.reports' },
{ id: 'email-config', label: t('menu.emailConfig'), icon: <EmailIcon />, path: '/admin/email-config', translationKey: 'menu.emailConfig' },
],
},
];