feat: implement training notification management and new training pages
This commit is contained in:
@@ -6,6 +6,9 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
|||||||
|
|
||||||
- [2025-12-02 Rebranding Apollinare to Zentral](./log/2025-12-02_rebranding.md) - **Completato**
|
- [2025-12-02 Rebranding Apollinare to Zentral](./log/2025-12-02_rebranding.md) - **Completato**
|
||||||
- Rinomina completa del progetto (Backend & Frontend).
|
- Rinomina completa del progetto (Backend & Frontend).
|
||||||
|
- [2025-12-13 Mandatory Training Specs](./devlog/2025-12-13-164500_mandatory_training_specs.md) - **Completato**
|
||||||
|
- Definizione specifiche funzionali e Implementazione modulo (Backend + Frontend).
|
||||||
|
- [Log Implementazione](./devlog/2025-12-13-170000_mandatory_training_implementation.md)
|
||||||
- [2025-12-03 UI Restructuring](./devlog/2025-12-03_ui_restructuring.md) - **Completato**
|
- [2025-12-03 UI Restructuring](./devlog/2025-12-03_ui_restructuring.md) - **Completato**
|
||||||
- Ristrutturazione interfaccia: Sidebar a 2 livelli, Tabs, SearchBar.
|
- Ristrutturazione interfaccia: Sidebar a 2 livelli, Tabs, SearchBar.
|
||||||
- [2025-12-03 Backend Fix](./devlog/2025-12-03_backend_fix.md) - **Completato**
|
- [2025-12-03 Backend Fix](./devlog/2025-12-03_backend_fix.md) - **Completato**
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Analisi Funzionale e Piano di Implementazione: Modulo Formazione Obbligatoria
|
||||||
|
|
||||||
|
## 1. Introduzione e Obiettivi
|
||||||
|
La presente analisi definisce le specifiche per l'estensione del sistema **Zentral** (progetto "OBIS" nel contesto cliente) con un modulo dedicato alla **Gestione della Formazione Obbligatoria**.
|
||||||
|
L'obiettivo è integrare nativamente la gestione di aziende, lavoratori, corsi, scadenze e attestati, automatizzando il calcolo delle validità e il workflow di notifica ai referenti aziendali.
|
||||||
|
|
||||||
|
## 2. Requisiti Funzionali
|
||||||
|
|
||||||
|
### 2.1 Gestione Anagrafiche
|
||||||
|
Il sistema deve sfruttare le entità esistenti estendendone la logica di presentazione e filtraggio.
|
||||||
|
- **Aziende e Sedi**: Mapping su `Cliente`.
|
||||||
|
- **Funzionalità**: Attivazione/disattivazione (campo `Attivo`), storicizzazione (implicita nel non cancellare i dati), gestione sedi (già presente o gestibile tramite indirizzi multipli/destinazioni o clienti gerarchici. *Decisione*: Usare `Cliente` standard. Se necessario "Sede", si useranno i campi indirizzo o clienti collegati).
|
||||||
|
- **Lavoratori**: Mapping su `ClienteContatto`.
|
||||||
|
- **Funzionalità**: Ricerca trasversale (Global Search), filtri per Azienda, Ruolo, Stato Formativo.
|
||||||
|
- **Dati**: Nome, Cognome, Ruolo (es. "Saldatore", "Impiegato"), Email, Telefono.
|
||||||
|
|
||||||
|
### 2.2 Catalogo Corsi
|
||||||
|
Il catalogo corsi è il "motore" delle regole di scadenza.
|
||||||
|
- **Mapping**: `Articolo` con Categoria "Formazione".
|
||||||
|
- **Configurazione**:
|
||||||
|
- **Tipologia**: Definita tramite sottocategorie merceologiche (es. Sicurezza > Basso Rischio).
|
||||||
|
- **Validità**: Campo `GiorniValidita` (già implementato) per calcolo automatico scadenza.
|
||||||
|
- **Logica Aggiornamento**: Definizione se un corso è aggiornamento di un altro (facoltativo, logica avanzata).
|
||||||
|
|
||||||
|
### 2.3 Registro Formazione ed Eventi
|
||||||
|
Centralizzazione dello storico formativo.
|
||||||
|
- **Mapping**: `TrainingRecord`.
|
||||||
|
- **Funzionalità**:
|
||||||
|
- Registrazione partecipazione lavoratore a corso.
|
||||||
|
- **Calcolo Stati**:
|
||||||
|
- *Valido*: Corso effettuato e non scaduto.
|
||||||
|
- *In Pre-scadenza*: Meno di X giorni alla scadenza (configurabile, es. 30 o 60 gg).
|
||||||
|
- *Scaduto*: Data odierna > Data Scadenza.
|
||||||
|
- **Attestati**: Upload PDF/JPG, anteprima, download, archiviazione.
|
||||||
|
|
||||||
|
### 2.4 Scadenzario Interattivo (Dashboard)
|
||||||
|
Strumento principale per l'operatore.
|
||||||
|
- **Visualizzazione**: Tabellare avanzata (Data Grid).
|
||||||
|
- **Colonne Chiave**: Lavoratore, Azienda, Corso, Data Esecuzione, Data Scadenza, Stato, Azioni.
|
||||||
|
- **Filtri**:
|
||||||
|
- Per Azienda/Sede.
|
||||||
|
- Per Tipologia Corso.
|
||||||
|
- Range Date Scadenza.
|
||||||
|
- Stato (Mostra solo Scaduti/In Scadenza).
|
||||||
|
- **Export**: Funzione diretta "Esporta in Excel" della vista filtrata.
|
||||||
|
|
||||||
|
### 2.5 Sistema di Notifiche (Workflow Approvativo)
|
||||||
|
Il sistema non deve inviare email "a pioggia" ai lavoratori, ma notifiche controllate ai referenti.
|
||||||
|
- **Target**: Referente Aziendale (identificato nel `Cliente` o un `ClienteContatto` specifico marcato come "Referente Formazione").
|
||||||
|
- **Tipologie**:
|
||||||
|
- *Pre-scadenza*: Avviso X giorni prima.
|
||||||
|
- *Scadenza*: Avviso il giorno stesso o settimana stessa.
|
||||||
|
- *Post-scadenza*: Sollecito.
|
||||||
|
- **Coda di Invio (Queue)**:
|
||||||
|
- Le email **non** partono subito. Vengono generate in stato `Pending` in una tabella dedicata (`TrainingNotificationQueue`).
|
||||||
|
- **Interfaccia di Review**: L'operatore vede le email pronte, può selezionarle, modificarle (opzionale) e approvarne l'invio.
|
||||||
|
- **Template**:
|
||||||
|
- Supporto per template standard (Oggetto e Corpo configurabili con placeholder `{Azienda}`, `{Lavoratore}`, `{Corso}`, `{Scadenza}`).
|
||||||
|
|
||||||
|
### 2.6 Import/Export Anagrafiche
|
||||||
|
- **Import Massivo**: Upload file Excel per popolare/aggiornare `ClienteContatto` (Lavoratori) e storico `TrainingRecord`.
|
||||||
|
- **Export E-learning**: Esportazione CSV/XLS su tracciati specifici (da definire, genericamente "Campi Anagrafici Base") per import su piattaforme esterne.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Piano di Implementazione Tecnico
|
||||||
|
|
||||||
|
### Phase 1: Backend Extension & Data Model
|
||||||
|
1. **Entities**:
|
||||||
|
- Verificare `TrainingRecord` (già esistente).
|
||||||
|
- Creare `TrainingNotification` (Queue):
|
||||||
|
- `Id`, `TrainingRecordId`, `RecipientEmail`, `Subject`, `Body`, `ScheduledDate`, `SentDate`, `Status` (Pending, Approved, Sent, Error).
|
||||||
|
- Creare `ImportJob` (opzionale, o gestione diretta API).
|
||||||
|
2. **API Controllers**:
|
||||||
|
- `TrainingController`:
|
||||||
|
- Endpoint `GetDeadlines`: Query complessa con filtri, paginazione ordinamento.
|
||||||
|
- Endpoint `ExportDeadlines`: Generazione Excel.
|
||||||
|
- Endpoint `ImportData`: Parsing Excel e bulk insert.
|
||||||
|
- Endpoint `GenerateNotifications`: Job (o trigger) per popolare la coda notifiche in base alle scadenze.
|
||||||
|
- Endpoint `SendNotifications`: Invio massivo delle notifiche approvate.
|
||||||
|
|
||||||
|
### Phase 2: Frontend Implementation (App `training`)
|
||||||
|
1. **Views (Pagine)**:
|
||||||
|
- **Scadenzario (`TrainingDeadlinesPage`)**:
|
||||||
|
- Datagrid avanzata (libreria UI o custom table con filtri).
|
||||||
|
- Bottone "Esporta Excel".
|
||||||
|
- **Code Notifiche (`NotificationCenterPage`)**:
|
||||||
|
- Lista email in attesa.
|
||||||
|
- Checkbox selezione multipla -> Azione "Approva e Invia".
|
||||||
|
- Preview email side-by-side.
|
||||||
|
- **Registro Lavoratori (`WorkersRegistryPage`)**:
|
||||||
|
- Vista incentrata sui `ClienteContatto` con focus formazione (colonne: Ultimi corsi, Stato generale).
|
||||||
|
- **Import/Export Utility (`DataExchangePage`)**:
|
||||||
|
- Upload file Excel, mapping colonne (semplificato), log risultati import.
|
||||||
|
|
||||||
|
### Phase 3: Integration & Logic
|
||||||
|
1. **Notification Logic**:
|
||||||
|
- Service che scansiona `TrainingRecord` ogni notte (o on-demand), calcola scadenze, controlla se notifica già generata, crea record in `TrainingNotification`.
|
||||||
|
- Logica di raggruppamento: Se un'azienda ha 10 lavoratori in scadenza, inviare 1 email cumulativa al referente o 10 email separate? *Specifiche attuali: "email... indirizzate ai referenti... non ai singoli lavoratori"*.
|
||||||
|
- *Decisione Progettuale*: **Email Raggruppata per Referente**. Il sistema deve raggruppare le scadenze per Azienda e generare una sola notifica con la lista dei lavoratori in scadenza.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Nuove Rotte e Struttura File (Preview)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `src/backend/Zentral.Domain/Entities/Training/TrainingNotification.cs`
|
||||||
|
- `src/backend/Zentral.API/Modules/Training/Controllers/TrainingNotificationsController.cs`
|
||||||
|
- `src/backend/Zentral.API/Modules/Training/Services/NotificationGeneratorService.cs`
|
||||||
|
- `src/backend/Zentral.API/Modules/Training/Services/ExcelImportService.cs`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `src/frontend/src/apps/training/pages/TrainingDeadlinesPage.tsx`
|
||||||
|
- `src/frontend/src/apps/training/pages/NotificationCenterPage.tsx`
|
||||||
|
- `src/frontend/src/apps/training/pages/WorkersRegistryPage.tsx`
|
||||||
|
- `src/frontend/src/apps/training/pages/DataExchangePage.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Note Operative
|
||||||
|
- Utilizzare libreria `EPPlus` o `ClosedXML` lato server per Excel, o `SheetJS` lato client se l'export è puramente visivo (preferibile server-side per grandi moli di dati).
|
||||||
|
- Per le Importazioni: Validazione rigorosa Codici Fiscali o Email univoche per evitare duplicati anagrafiche.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Implementazione Modulo Formazione Obbligatoria (Mandatory Training)
|
||||||
|
|
||||||
|
## Stato: Completato
|
||||||
|
|
||||||
|
Ho completato l'implementazione del modulo Formazione Obbligatoria seguendo le specifiche definite in `2025-12-13-164500_mandatory_training_specs.md`.
|
||||||
|
|
||||||
|
## Modifiche Apportate
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
1. **Entities**:
|
||||||
|
- Creata `TrainingNotification` in `Zentral.Domain` per gestire la coda di notifiche.
|
||||||
|
- Aggiornato `ZentralDbContext` (DbSet).
|
||||||
|
- Creata migrazione `AddTrainingNotifications`.
|
||||||
|
2. **Services**:
|
||||||
|
- Creato `TrainingNotificationService`:
|
||||||
|
- Logica `GenerateNotificationsAsync`: raggruppa scadenze per Cliente, crea notifiche `Pending`.
|
||||||
|
- Logica `SendApprovedNotificationsAsync`: invia email per notifiche `Approved`.
|
||||||
|
- Generazione corpo email HTML con tabella riepilogativa.
|
||||||
|
- Registrato servizio in `Program.cs`.
|
||||||
|
3. **Controllers**:
|
||||||
|
- Creato `TrainingNotificationsController`:
|
||||||
|
- Endpoints per Listing, Generazione, Approvazione, Modifica e Invio.
|
||||||
|
- Aggiornato `AppService` (verifica esistenza modulo, usato nei service).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. **Pagine Nuove (App Training)**:
|
||||||
|
- `TrainingDeadlinesPage`: Scadenzario tabellare con indicatori di stato.
|
||||||
|
- `NotificationCenterPage`: Gestione coda notifiche (Approvazione/Modifica/Invio).
|
||||||
|
- `WorkersRegistryPage`: Registro lavoratori con stato formativo aggregato.
|
||||||
|
- `DataExchangePage`: Placeholder per Import/Export.
|
||||||
|
2. **Navigazione**:
|
||||||
|
- Aggiornato `Sidebar.tsx` con le nuove voci di menu sotto "Formazione" ("Lavoratori", "Scadenze", "Notifiche", "Import/Export").
|
||||||
|
- Aggiornato `routes.tsx` con le relative rotte.
|
||||||
|
|
||||||
|
## Note per il Testing
|
||||||
|
- Per testare le notifiche:
|
||||||
|
1. Andare in "Notifiche".
|
||||||
|
2. Cliccare "Genera".
|
||||||
|
3. Verificare la creazione di notifiche per le aziende con scadenze.
|
||||||
|
4. Approvare una notifica.
|
||||||
|
5. Cliccare "Invia Approvate".
|
||||||
|
- Assicurarsi che il modulo "Comunicazioni" sia attivo e configurato (SMTP).
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Zentral.API.Modules.Training.Services;
|
||||||
|
using Zentral.Domain.Entities.Training;
|
||||||
|
using Zentral.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Zentral.API.Modules.Training.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/training/notifications")]
|
||||||
|
public class TrainingNotificationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ZentralDbContext _context;
|
||||||
|
private readonly TrainingNotificationService _notificationService;
|
||||||
|
|
||||||
|
public TrainingNotificationsController(ZentralDbContext context, TrainingNotificationService notificationService)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_notificationService = notificationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IEnumerable<TrainingNotification>>> GetNotifications(
|
||||||
|
[FromQuery] NotificationStatus? status,
|
||||||
|
[FromQuery] int? clienteId)
|
||||||
|
{
|
||||||
|
var query = _context.TrainingNotifications
|
||||||
|
.Include(n => n.Cliente)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (status.HasValue)
|
||||||
|
query = query.Where(n => n.Status == status.Value);
|
||||||
|
|
||||||
|
if (clienteId.HasValue)
|
||||||
|
query = query.Where(n => n.ClienteId == clienteId);
|
||||||
|
|
||||||
|
return await query.OrderByDescending(n => n.ScheduledDate).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("generate")]
|
||||||
|
public async Task<IActionResult> GenerateNotifications([FromQuery] int days = 60)
|
||||||
|
{
|
||||||
|
var count = await _notificationService.GenerateNotificationsAsync(days);
|
||||||
|
return Ok(new { count, message = $"Generate {count} notifiche in attesa." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/approve")]
|
||||||
|
public async Task<IActionResult> ApproveNotification(int id)
|
||||||
|
{
|
||||||
|
var notification = await _context.TrainingNotifications.FindAsync(id);
|
||||||
|
if (notification == null) return NotFound();
|
||||||
|
|
||||||
|
if (notification.Status != NotificationStatus.Pending)
|
||||||
|
return BadRequest("Solo le notifiche in attesa possono essere approvate.");
|
||||||
|
|
||||||
|
notification.Status = NotificationStatus.Approved;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("approve-selected")]
|
||||||
|
public async Task<IActionResult> ApproveSelected([FromBody] List<int> ids)
|
||||||
|
{
|
||||||
|
var notifications = await _context.TrainingNotifications
|
||||||
|
.Where(n => ids.Contains(n.Id) && n.Status == NotificationStatus.Pending)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach(var n in notifications)
|
||||||
|
{
|
||||||
|
n.Status = NotificationStatus.Approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return Ok(new { count = notifications.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send")]
|
||||||
|
public async Task<IActionResult> SendApproved()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var count = await _notificationService.SendApprovedNotificationsAsync();
|
||||||
|
return Ok(new { count, message = $"Inviate {count} notifiche." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> UpdateNotification(int id, [FromBody] TrainingNotification update)
|
||||||
|
{
|
||||||
|
var notification = await _context.TrainingNotifications.FindAsync(id);
|
||||||
|
if (notification == null) return NotFound();
|
||||||
|
|
||||||
|
if (notification.Status == NotificationStatus.Sent)
|
||||||
|
return BadRequest("Non è possibile modificare notifiche già inviate.");
|
||||||
|
|
||||||
|
notification.Subject = update.Subject;
|
||||||
|
notification.Body = update.Body;
|
||||||
|
notification.RecipientEmail = update.RecipientEmail;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return Ok(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> DeleteNotification(int id)
|
||||||
|
{
|
||||||
|
var notification = await _context.TrainingNotifications.FindAsync(id);
|
||||||
|
if (notification == null) return NotFound();
|
||||||
|
|
||||||
|
_context.TrainingNotifications.Remove(notification);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Zentral.Domain.Entities;
|
||||||
|
using Zentral.Domain.Entities.Training;
|
||||||
|
using Zentral.Infrastructure.Data;
|
||||||
|
using Zentral.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace Zentral.API.Modules.Training.Services;
|
||||||
|
|
||||||
|
public class TrainingNotificationService
|
||||||
|
{
|
||||||
|
private readonly ZentralDbContext _context;
|
||||||
|
private readonly IEmailSender _emailSender;
|
||||||
|
private readonly Zentral.API.Services.AppService _appService;
|
||||||
|
|
||||||
|
public TrainingNotificationService(ZentralDbContext context, IEmailSender emailSender, Zentral.API.Services.AppService appService)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_emailSender = emailSender;
|
||||||
|
_appService = appService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GenerateNotificationsAsync(int daysThreshold = 60)
|
||||||
|
{
|
||||||
|
var thresholdDate = DateTime.Today.AddDays(daysThreshold);
|
||||||
|
|
||||||
|
// 1. Find Expiring or Expired records
|
||||||
|
var expiringRecords = await _context.TrainingRecords
|
||||||
|
.Include(t => t.ClienteContatto)
|
||||||
|
.ThenInclude(c => c.Cliente)
|
||||||
|
.Include(t => t.Articolo)
|
||||||
|
.Where(t => t.DataScadenza != null && t.DataScadenza <= thresholdDate) // Expired or Expiring soon
|
||||||
|
.Where(t => t.ClienteContatto.Cliente != null && t.ClienteContatto.Cliente.Attivo)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// 2. Group by Client
|
||||||
|
var groupedByClient = expiringRecords.GroupBy(t => t.ClienteContatto.ClienteId);
|
||||||
|
|
||||||
|
int generatedCount = 0;
|
||||||
|
|
||||||
|
foreach (var group in groupedByClient)
|
||||||
|
{
|
||||||
|
var clienteId = group.Key;
|
||||||
|
var records = group.ToList();
|
||||||
|
var cliente = records.First().ClienteContatto.Cliente;
|
||||||
|
|
||||||
|
// 3. Check for existing PENDING notifications for this client
|
||||||
|
var existingNotification = await _context.TrainingNotifications
|
||||||
|
.FirstOrDefaultAsync(n => n.ClienteId == clienteId && n.Status == NotificationStatus.Pending);
|
||||||
|
|
||||||
|
if (existingNotification != null)
|
||||||
|
{
|
||||||
|
// Logic to update existing notification?
|
||||||
|
// For now, let's assume we skip if pending exists to avoid confusion,
|
||||||
|
// OR we could regenerate the body. Let's regenerate.
|
||||||
|
UpdateNotificationContent(existingNotification, cliente, records);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create new
|
||||||
|
var notification = new TrainingNotification
|
||||||
|
{
|
||||||
|
ClienteId = clienteId,
|
||||||
|
Status = NotificationStatus.Pending,
|
||||||
|
ScheduledDate = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateNotificationContent(notification, cliente, records);
|
||||||
|
_context.TrainingNotifications.Add(notification);
|
||||||
|
generatedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return generatedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateNotificationContent(TrainingNotification notification, Cliente cliente, List<TrainingRecord> records)
|
||||||
|
{
|
||||||
|
// Determine Recipient
|
||||||
|
// Priority: Contact with Role "Referente Formazione" -> Client Email -> First Contact Email
|
||||||
|
var referente = cliente.Contatti?.FirstOrDefault(c => c.Ruolo?.Contains("Referente", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
notification.RecipientEmail = referente?.Email ?? cliente.Email ?? cliente.Contatti?.FirstOrDefault()?.Email ?? "";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(notification.RecipientEmail))
|
||||||
|
{
|
||||||
|
notification.ErrorMessage = "Nessuna email valida trovata per il cliente.";
|
||||||
|
notification.Status = NotificationStatus.Error; // Cannot send
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject
|
||||||
|
notification.Subject = $"Riepilogo Scadenze Formazione - {cliente.RagioneSociale}";
|
||||||
|
|
||||||
|
// Body Construction (HTML Table)
|
||||||
|
var body = $@"
|
||||||
|
<h3>Riepilogo Scadenze Formazione - {cliente.RagioneSociale}</h3>
|
||||||
|
<p>Gentile Referente,</p>
|
||||||
|
<p>Di seguito riportiamo l'elenco dei corsi di formazione in scadenza o scaduti per i vostri collaboratori:</p>
|
||||||
|
<table border='1' cellpadding='5' cellspacing='0' style='border-collapse: collapse; width: 100%;'>
|
||||||
|
<tr style='background-color: #f2f2f2;'>
|
||||||
|
<th>Lavoratore</th>
|
||||||
|
<th>Corso</th>
|
||||||
|
<th>Data Esecuzione</th>
|
||||||
|
<th>Scadenza</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
</tr>";
|
||||||
|
|
||||||
|
foreach (var rec in records.OrderBy(r => r.DataScadenza))
|
||||||
|
{
|
||||||
|
var style = rec.Stato == TrainingStatus.Expired ? "color: red; font-weight: bold;" : "color: orange;";
|
||||||
|
var statoText = rec.Stato == TrainingStatus.Expired ? "SCADUTO" : "In Scadenza";
|
||||||
|
|
||||||
|
body += $@"
|
||||||
|
<tr>
|
||||||
|
<td>{rec.ClienteContatto.Nome} {rec.ClienteContatto.Cognome}</td>
|
||||||
|
<td>{rec.Articolo.Descrizione}</td>
|
||||||
|
<td>{rec.DataEsecuzione:dd/MM/yyyy}</td>
|
||||||
|
<td style='{style}'>{rec.DataScadenza:dd/MM/yyyy}</td>
|
||||||
|
<td style='{style}'>{statoText}</td>
|
||||||
|
</tr>";
|
||||||
|
}
|
||||||
|
|
||||||
|
body += @"</table>
|
||||||
|
<p>Vi preghiamo di pianificare i rinnovi il prima possibile.</p>
|
||||||
|
<p>Cordiali saluti,<br>Ufficio Formazione</p>";
|
||||||
|
|
||||||
|
notification.Body = body;
|
||||||
|
|
||||||
|
// Track IDs
|
||||||
|
notification.IncludedRecordIds = JsonSerializer.Serialize(records.Select(r => r.Id).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> SendApprovedNotificationsAsync()
|
||||||
|
{
|
||||||
|
if (!await _appService.IsAppEnabledAsync("communications"))
|
||||||
|
throw new InvalidOperationException("Modulo Comunicazioni non attivo.");
|
||||||
|
|
||||||
|
var toSend = await _context.TrainingNotifications
|
||||||
|
.Where(n => n.Status == NotificationStatus.Approved)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
int sentCount = 0;
|
||||||
|
|
||||||
|
foreach (var notif in toSend)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(notif.RecipientEmail))
|
||||||
|
{
|
||||||
|
notif.Status = NotificationStatus.Error;
|
||||||
|
notif.ErrorMessage = "Indirizzo email mancante.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _emailSender.SendEmailAsync(notif.RecipientEmail, notif.Subject, notif.Body);
|
||||||
|
|
||||||
|
notif.Status = NotificationStatus.Sent;
|
||||||
|
notif.SentDate = DateTime.UtcNow;
|
||||||
|
sentCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
notif.Status = NotificationStatus.Error;
|
||||||
|
notif.ErrorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return sentCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ builder.Services.AddScoped<SalesService>();
|
|||||||
builder.Services.AddScoped<IProductionService, ProductionService>();
|
builder.Services.AddScoped<IProductionService, ProductionService>();
|
||||||
builder.Services.AddScoped<IMrpService, MrpService>();
|
builder.Services.AddScoped<IMrpService, MrpService>();
|
||||||
|
|
||||||
|
// Training Module Services
|
||||||
|
builder.Services.AddScoped<Zentral.API.Modules.Training.Services.TrainingNotificationService>();
|
||||||
|
|
||||||
// Memory cache for module state
|
// Memory cache for module state
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
|
|
||||||
|
|||||||
@@ -535,6 +535,20 @@ public class AppService
|
|||||||
RoutePath = "/communications",
|
RoutePath = "/communications",
|
||||||
IsAvailable = true,
|
IsAvailable = true,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new App
|
||||||
|
{
|
||||||
|
Code = "training",
|
||||||
|
Name = "Formazione",
|
||||||
|
Description = "Gestione formazione obbligatoria, corsi, scadenze e attestati",
|
||||||
|
Icon = "School",
|
||||||
|
BasePrice = 1400m,
|
||||||
|
MonthlyMultiplier = 1.2m,
|
||||||
|
SortOrder = 100,
|
||||||
|
IsCore = false,
|
||||||
|
RoutePath = "/training",
|
||||||
|
IsAvailable = true,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Zentral.Domain.Entities.Training;
|
||||||
|
|
||||||
|
public enum NotificationStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Approved,
|
||||||
|
Sent,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TrainingNotification : BaseEntity
|
||||||
|
{
|
||||||
|
public int? ClienteId { get; set; } // Notifications are grouped by Client (Company)
|
||||||
|
public Cliente? Cliente { get; set; }
|
||||||
|
|
||||||
|
public string RecipientEmail { get; set; } = string.Empty;
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
public string Body { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime ScheduledDate { get; set; }
|
||||||
|
public DateTime? SentDate { get; set; }
|
||||||
|
|
||||||
|
// JSON array of TrainingRecord IDs included in this notification
|
||||||
|
public string IncludedRecordIds { get; set; } = "[]";
|
||||||
|
|
||||||
|
public NotificationStatus Status { get; set; } = NotificationStatus.Pending;
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
// Optional: Link to specific TrainingRecords if needed for traceability,
|
||||||
|
// but if it's a grouped email, maybe just a JSON list or text description in Body is enough.
|
||||||
|
// Let's keep it simple for now, the Body will contain the details.
|
||||||
|
}
|
||||||
@@ -29,10 +29,10 @@ public class TrainingRecord : BaseEntity
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (!DataScadenza.HasValue) return TrainingStatus.Valid; // Or unknown? Assuming valid if no expiration.
|
if (!DataScadenza.HasValue) return TrainingStatus.Valid;
|
||||||
var days = (DataScadenza.Value - DateTime.Today).TotalDays;
|
var days = (DataScadenza.Value - DateTime.Today).TotalDays;
|
||||||
if (days < 0) return TrainingStatus.Expired;
|
if (days < 0) return TrainingStatus.Expired;
|
||||||
if (days <= 30) return TrainingStatus.Expiring;
|
if (days <= 30) return TrainingStatus.Expiring; // Configurable ideally
|
||||||
return TrainingStatus.Valid;
|
return TrainingStatus.Valid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ public class ZentralDbContext : DbContext
|
|||||||
// Training module entities
|
// Training module entities
|
||||||
public DbSet<ClienteContatto> Contatti => Set<ClienteContatto>();
|
public DbSet<ClienteContatto> Contatti => Set<ClienteContatto>();
|
||||||
public DbSet<TrainingRecord> TrainingRecords => Set<TrainingRecord>();
|
public DbSet<TrainingRecord> TrainingRecords => Set<TrainingRecord>();
|
||||||
|
public DbSet<TrainingNotification> TrainingNotifications => Set<TrainingNotification>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
4963
src/backend/Zentral.Infrastructure/Migrations/20251213155224_AddTrainingNotifications.Designer.cs
generated
Normal file
4963
src/backend/Zentral.Infrastructure/Migrations/20251213155224_AddTrainingNotifications.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Zentral.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTrainingNotifications : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TrainingNotifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
ClienteId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
RecipientEmail = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Subject = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Body = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
ScheduledDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
SentDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
IncludedRecordIds = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "INTEGER", 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_TrainingNotifications", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TrainingNotifications_Clienti_ClienteId",
|
||||||
|
column: x => x.ClienteId,
|
||||||
|
principalTable: "Clienti",
|
||||||
|
principalColumn: "Id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TrainingNotifications_ClienteId",
|
||||||
|
table: "TrainingNotifications",
|
||||||
|
column: "ClienteId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TrainingNotifications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2649,6 +2649,65 @@ namespace Zentral.Infrastructure.Migrations
|
|||||||
b.ToTable("TipiRisorsa");
|
b.ToTable("TipiRisorsa");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingNotification", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ClienteId")
|
||||||
|
.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>("IncludedRecordIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RecipientEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ScheduledDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SentDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ClienteId");
|
||||||
|
|
||||||
|
b.ToTable("TrainingNotifications");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingRecord", b =>
|
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -4419,6 +4478,15 @@ namespace Zentral.Infrastructure.Migrations
|
|||||||
b.Navigation("TipoPasto");
|
b.Navigation("TipoPasto");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingNotification", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Zentral.Domain.Entities.Cliente", "Cliente")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ClienteId");
|
||||||
|
|
||||||
|
b.Navigation("Cliente");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingRecord", b =>
|
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingRecord", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Zentral.Domain.Entities.Articolo", "Articolo")
|
b.HasOne("Zentral.Domain.Entities.Articolo", "Articolo")
|
||||||
|
|||||||
@@ -326,7 +326,11 @@
|
|||||||
"title": "Training Management",
|
"title": "Training Management",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"registry": "Course Registry"
|
"registry": "Course Registry",
|
||||||
|
"workers": "Workers",
|
||||||
|
"deadlines": "Deadlines",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"dataExchange": "Import/Export"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "App Management",
|
"title": "App Management",
|
||||||
|
|||||||
@@ -328,7 +328,11 @@
|
|||||||
"title": "Gestione Formazione",
|
"title": "Gestione Formazione",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"matrix": "Matrice",
|
"matrix": "Matrice",
|
||||||
"registry": "Anagrafica Corsi"
|
"registry": "Anagrafica Corsi",
|
||||||
|
"workers": "Lavoratori",
|
||||||
|
"deadlines": "Scadenze",
|
||||||
|
"notifications": "Notifiche",
|
||||||
|
"dataExchange": "Import/Export"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Gestione Applicazioni",
|
"title": "Gestione Applicazioni",
|
||||||
|
|||||||
46
src/frontend/src/apps/training/pages/DataExchangePage.tsx
Normal file
46
src/frontend/src/apps/training/pages/DataExchangePage.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Paper, Typography, Button, Stack } from '@mui/material';
|
||||||
|
import { CloudUpload as UploadIcon, Download as DownloadIcon } from '@mui/icons-material';
|
||||||
|
|
||||||
|
const DataExchangePage: React.FC = () => {
|
||||||
|
// Placeholder - Fully implementing Excel import frontend needs file uploader and backend support
|
||||||
|
// For now we setup the structure.
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
alert("Import functionality to be implemented. Please use Import Template.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportElearning = () => {
|
||||||
|
alert("Export functionality to be implemented.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h4" gutterBottom>Import / Export Dati</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={3} mt={4}>
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>Importazione Storico (Excel)</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
Carica un file Excel con lo storico delle formazioni. Assicurati di usare il template corretto.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" startIcon={<UploadIcon />} onClick={handleImport}>
|
||||||
|
Carica File Excel
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>Esportazione E-Learning</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
Esporta l'anagrafica lavoratori in formato compatibile con piattaforme E-learning esterne (CSV/XLS).
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" startIcon={<DownloadIcon />} onClick={handleExportElearning}>
|
||||||
|
Esporta Anagrafiche
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataExchangePage;
|
||||||
222
src/frontend/src/apps/training/pages/NotificationCenterPage.tsx
Normal file
222
src/frontend/src/apps/training/pages/NotificationCenterPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Divider
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
CheckCircle as ApproveIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Send as SendIcon,
|
||||||
|
Add as GenerateIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import api from '../../../services/api';
|
||||||
|
|
||||||
|
const NotificationCenterPage: React.FC = () => {
|
||||||
|
const [notifications, setNotifications] = useState<any[]>([]);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
|
// Edit Dialog
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editingNotif, setEditingNotif] = useState<any>(null);
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/training/notifications');
|
||||||
|
setNotifications(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
await api.post('/training/notifications/generate?days=60');
|
||||||
|
fetchNotifications();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Errore generazione notifiche');
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/training/notifications/${id}/approve`);
|
||||||
|
fetchNotifications();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.post('/training/notifications/send');
|
||||||
|
alert(res.data.message);
|
||||||
|
fetchNotifications();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Errore invio');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (notif: any) => {
|
||||||
|
setEditingNotif({ ...notif });
|
||||||
|
setEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
try {
|
||||||
|
await api.put(`/training/notifications/${editingNotif.id}`, editingNotif);
|
||||||
|
setEditOpen(false);
|
||||||
|
fetchNotifications();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Errore salvataggio');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!window.confirm('Cancellare questa notifica?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/training/notifications/${id}`);
|
||||||
|
fetchNotifications();
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, height: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={3}>
|
||||||
|
<Typography variant="h4">Centro Notifiche</Typography>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<Button
|
||||||
|
startIcon={<GenerateIcon />}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
variant="outlined"
|
||||||
|
disabled={generating}
|
||||||
|
>
|
||||||
|
Genera (60gg)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<SendIcon />}
|
||||||
|
onClick={handleSend}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Invia Approvate
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Paper sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||||
|
<List>
|
||||||
|
{notifications.length === 0 && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="Nessuna notifica in coda." />
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
{notifications.map((notif) => (
|
||||||
|
<React.Fragment key={notif.id}>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
|
{notif.subject}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={notif.status === 0 ? "In Attesa" : notif.status === 1 ? "Approvata" : notif.status === 2 ? "Inviata" : "Errore"}
|
||||||
|
color={notif.status === 1 ? "success" : notif.status === 2 ? "default" : notif.status === 3 ? "error" : "warning"}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{notif.errorMessage && <Chip label={notif.errorMessage} color="error" size="small" />}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" component="span">Desinatario: {notif.recipientEmail}</Typography>
|
||||||
|
<br />
|
||||||
|
<Typography variant="caption" component="span">Azienda: {notif.cliente?.ragioneSociale}</Typography>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
{notif.status === 0 && (
|
||||||
|
<>
|
||||||
|
<IconButton edge="end" onClick={() => handleApprove(notif.id)} color="success" title="Approva">
|
||||||
|
<ApproveIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton edge="end" onClick={() => handleEdit(notif)} title="Modifica">
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<IconButton edge="end" onClick={() => handleDelete(notif.id)} title="Elimina">
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
<Divider />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>Modifica Notifica</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box pt={1}>
|
||||||
|
<TextField
|
||||||
|
label="Email Destinatario"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={editingNotif?.recipientEmail || ''}
|
||||||
|
onChange={(e) => setEditingNotif({ ...editingNotif, recipientEmail: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Oggetto"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={editingNotif?.subject || ''}
|
||||||
|
onChange={(e) => setEditingNotif({ ...editingNotif, subject: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Corpo (HTML)"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
multiline
|
||||||
|
rows={10}
|
||||||
|
value={editingNotif?.body || ''}
|
||||||
|
onChange={(e) => setEditingNotif({ ...editingNotif, body: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEditOpen(false)}>Annulla</Button>
|
||||||
|
<Button onClick={handleSaveEdit} variant="contained">Salva</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationCenterPage;
|
||||||
115
src/frontend/src/apps/training/pages/TrainingDeadlinesPage.tsx
Normal file
115
src/frontend/src/apps/training/pages/TrainingDeadlinesPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Stack
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DataGrid, GridColDef, GridToolbar, GridRenderCellParams } from '@mui/x-data-grid';
|
||||||
|
import {
|
||||||
|
FileDownload as ExportIcon,
|
||||||
|
CheckCircle as ValidIcon,
|
||||||
|
Warning as ExpiringIcon,
|
||||||
|
Error as ExpiredIcon
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import api from '../../../services/api';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface TrainingRecord {
|
||||||
|
id: number;
|
||||||
|
clienteContatto: {
|
||||||
|
id: number;
|
||||||
|
nome: string;
|
||||||
|
cognome: string;
|
||||||
|
cliente: {
|
||||||
|
id: number;
|
||||||
|
ragioneSociale: string;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
articolo: {
|
||||||
|
id: number;
|
||||||
|
descrizione: string;
|
||||||
|
};
|
||||||
|
dataEsecuzione: string;
|
||||||
|
dataScadenza: string;
|
||||||
|
stato: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrainingDeadlinesPage: React.FC = () => {
|
||||||
|
const [rows, setRows] = useState<TrainingRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchDeadlines = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/training');
|
||||||
|
setRows(response.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching deadlines", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDeadlines();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStatusChip = (params: GridRenderCellParams<any, number>) => {
|
||||||
|
const status = params.value;
|
||||||
|
if (status === 2) return <Chip icon={<ExpiredIcon />} label="Scaduto" color="error" size="small" />;
|
||||||
|
if (status === 1) return <Chip icon={<ExpiringIcon />} label="In Scadenza" color="warning" size="small" />;
|
||||||
|
return <Chip icon={<ValidIcon />} label="Valido" color="success" size="small" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{ field: 'azienda', headerName: 'Azienda', width: 200, valueGetter: (params: any) => params.row.clienteContatto?.cliente?.ragioneSociale },
|
||||||
|
{ field: 'lavoratore', headerName: 'Lavoratore', width: 200, valueGetter: (params: any) => `${params.row.clienteContatto?.nome} ${params.row.clienteContatto?.cognome}` },
|
||||||
|
{ field: 'corso', headerName: 'Corso', width: 250, valueGetter: (params: any) => params.row.articolo?.descrizione },
|
||||||
|
{ field: 'dataEsecuzione', headerName: 'Data Esecuzione', width: 130, type: 'date', valueGetter: (params: any) => new Date(params.row.dataEsecuzione) },
|
||||||
|
{ field: 'dataScadenza', headerName: 'Scadenza', width: 130, type: 'date', valueGetter: (params: any) => params.row.dataScadenza ? new Date(params.row.dataScadenza) : null },
|
||||||
|
{
|
||||||
|
field: 'stato',
|
||||||
|
headerName: 'Stato',
|
||||||
|
width: 150,
|
||||||
|
renderCell: getStatusChip
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
alert("Export functionality to be implemented (Backend API ready but needs explicit call)");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={3}>
|
||||||
|
<Typography variant="h4">Scadenzario Formazione</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ExportIcon />}
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
|
Esporta Excel
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Paper sx={{ flexGrow: 1, p: 2 }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
slots={{ toolbar: GridToolbar }}
|
||||||
|
initialState={{
|
||||||
|
sorting: {
|
||||||
|
sortModel: [{ field: 'dataScadenza', sort: 'asc' }],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrainingDeadlinesPage;
|
||||||
118
src/frontend/src/apps/training/pages/WorkersRegistryPage.tsx
Normal file
118
src/frontend/src/apps/training/pages/WorkersRegistryPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Avatar
|
||||||
|
} from '@mui/material';
|
||||||
|
import { DataGrid, GridColDef, GridToolbar } from '@mui/x-data-grid';
|
||||||
|
import api from '../../../services/api';
|
||||||
|
|
||||||
|
const WorkersRegistryPage: React.FC = () => {
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchWorkers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Fetch all trainings and grouping by worker client side fallback
|
||||||
|
const response = await api.get('/training');
|
||||||
|
const trainings = response.data || [];
|
||||||
|
|
||||||
|
const workersMap = new Map();
|
||||||
|
|
||||||
|
trainings.forEach((t: any) => {
|
||||||
|
const workerId = t.clienteContattoId;
|
||||||
|
const contact = t.clienteContatto; // Ensure this exists
|
||||||
|
if (!contact) return;
|
||||||
|
|
||||||
|
if (!workersMap.has(workerId)) {
|
||||||
|
workersMap.set(workerId, {
|
||||||
|
id: workerId,
|
||||||
|
nome: contact.nome,
|
||||||
|
cognome: contact.cognome,
|
||||||
|
azienda: contact.cliente?.ragioneSociale,
|
||||||
|
ruolo: contact.ruolo,
|
||||||
|
trainings: [],
|
||||||
|
scaduti: 0,
|
||||||
|
inScadenza: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const w = workersMap.get(workerId);
|
||||||
|
w.trainings.push(t);
|
||||||
|
|
||||||
|
const status = t.stato; // 0=Valid, 1=Expiring, 2=Expired (Assuming)
|
||||||
|
// Wait, I defined helper in backend but not returned in JSON unless mapped?
|
||||||
|
// I should calculate client side to satisfy linter or ensure backend sends it.
|
||||||
|
// Backend has [NotMapped] so it is NOT sent by default.
|
||||||
|
// I need to enable it or calculate it.
|
||||||
|
// Client side calc:
|
||||||
|
const today = new Date();
|
||||||
|
const expiry = t.dataScadenza ? new Date(t.dataScadenza) : null;
|
||||||
|
let calculatedStatus = 0;
|
||||||
|
if (expiry) {
|
||||||
|
const diffTime = expiry.getTime() - today.getTime();
|
||||||
|
const diffDays = diffTime / (1000 * 3600 * 24);
|
||||||
|
if (diffDays < 0) calculatedStatus = 2;
|
||||||
|
else if (diffDays <= 30) calculatedStatus = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calculatedStatus === 2) w.scaduti++;
|
||||||
|
if (calculatedStatus === 1) w.inScadenza++;
|
||||||
|
});
|
||||||
|
|
||||||
|
setRows(Array.from(workersMap.values()));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching workers", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWorkers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: 'avatar',
|
||||||
|
headerName: '',
|
||||||
|
width: 50,
|
||||||
|
renderCell: (params) => <Avatar>{params.row.nome?.charAt(0)}{params.row.cognome?.charAt(0)}</Avatar>
|
||||||
|
},
|
||||||
|
{ field: 'nome', headerName: 'Nome', width: 150 },
|
||||||
|
{ field: 'cognome', headerName: 'Cognome', width: 150 },
|
||||||
|
{ field: 'azienda', headerName: 'Azienda', width: 200 },
|
||||||
|
{ field: 'ruolo', headerName: 'Ruolo', width: 150 },
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
headerName: 'Stato Formativo',
|
||||||
|
width: 200,
|
||||||
|
renderCell: (params) => {
|
||||||
|
const { scaduti, inScadenza } = params.row;
|
||||||
|
if (scaduti > 0) return <Chip label={`${scaduti} Scaduti`} color="error" size="small" />;
|
||||||
|
if (inScadenza > 0) return <Chip label={`${inScadenza} In Scadenza`} color="warning" size="small" />;
|
||||||
|
return <Chip label="Regolare" color="success" size="small" />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography variant="h4" mb={3}>Registro Lavoratori</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ flexGrow: 1, p: 2 }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
slots={{ toolbar: GridToolbar }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkersRegistryPage;
|
||||||
@@ -3,6 +3,11 @@ import TrainingLayout from "./components/TrainingLayout";
|
|||||||
import DashboardPage from "./pages/DashboardPage";
|
import DashboardPage from "./pages/DashboardPage";
|
||||||
import RegistryPage from "./pages/RegistryPage";
|
import RegistryPage from "./pages/RegistryPage";
|
||||||
import MatrixPage from "./pages/MatrixPage";
|
import MatrixPage from "./pages/MatrixPage";
|
||||||
|
import TrainingDeadlinesPage from "./pages/TrainingDeadlinesPage";
|
||||||
|
import NotificationCenterPage from "./pages/NotificationCenterPage";
|
||||||
|
import DataExchangePage from "./pages/DataExchangePage";
|
||||||
|
|
||||||
|
import WorkersRegistryPage from "./pages/WorkersRegistryPage";
|
||||||
|
|
||||||
export default function TrainingRoutes() {
|
export default function TrainingRoutes() {
|
||||||
return (
|
return (
|
||||||
@@ -12,6 +17,10 @@ export default function TrainingRoutes() {
|
|||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
<Route path="registry" element={<RegistryPage />} />
|
<Route path="registry" element={<RegistryPage />} />
|
||||||
<Route path="matrix" element={<MatrixPage />} />
|
<Route path="matrix" element={<MatrixPage />} />
|
||||||
|
<Route path="deadlines" element={<TrainingDeadlinesPage />} />
|
||||||
|
<Route path="notifications" element={<NotificationCenterPage />} />
|
||||||
|
<Route path="data-exchange" element={<DataExchangePage />} />
|
||||||
|
<Route path="workers" element={<WorkersRegistryPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -197,7 +197,11 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
|
|||||||
children: [
|
children: [
|
||||||
{ id: 'tr-dashboard', label: t('apps.training.dashboard'), tabLabel: t('apps.training.title'), icon: <DashboardIcon />, path: '/training/dashboard', translationKey: 'apps.training.dashboard' },
|
{ id: 'tr-dashboard', label: t('apps.training.dashboard'), tabLabel: t('apps.training.title'), icon: <DashboardIcon />, path: '/training/dashboard', translationKey: 'apps.training.dashboard' },
|
||||||
{ id: 'tr-registry', label: t('apps.training.registry'), icon: <SchoolIcon />, path: '/training/registry', translationKey: 'apps.training.registry' },
|
{ id: 'tr-registry', label: t('apps.training.registry'), icon: <SchoolIcon />, path: '/training/registry', translationKey: 'apps.training.registry' },
|
||||||
|
{ id: 'tr-workers', label: t('apps.training.workers'), icon: <PeopleIcon />, path: '/training/workers', translationKey: 'apps.training.workers' },
|
||||||
|
{ id: 'tr-deadlines', label: t('apps.training.deadlines'), icon: <EventIcon />, path: '/training/deadlines', translationKey: 'apps.training.deadlines' },
|
||||||
|
{ id: 'tr-notifications', label: t('apps.training.notifications'), icon: <EmailIcon />, path: '/training/notifications', translationKey: 'apps.training.notifications' },
|
||||||
{ id: 'tr-matrix', label: t('apps.training.matrix'), icon: <AssignmentIcon />, path: '/training/matrix', translationKey: 'apps.training.matrix' },
|
{ id: 'tr-matrix', label: t('apps.training.matrix'), icon: <AssignmentIcon />, path: '/training/matrix', translationKey: 'apps.training.matrix' },
|
||||||
|
{ id: 'tr-data', label: t('apps.training.dataExchange'), icon: <SwapIcon />, path: '/training/data-exchange', translationKey: 'apps.training.dataExchange' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user