feat: Implement training record notification system with UI and backend email integration, and ensure 'TRAIN' category seeding.

This commit is contained in:
2025-12-12 19:08:52 +01:00
parent 4810d49410
commit 99ce5e1e6a
9 changed files with 165 additions and 41 deletions

View File

@@ -49,6 +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. - 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** - [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato**
- [2025-12-12 Training Course Module](./devlog/2025-12-12-105500_training_course_module.md) - **Completato** - [2025-12-12 Training Course Module](./devlog/2025-12-12-105500_training_course_module.md) - **Completato**
- Implementazione gestione Corsi (sottocategorie Formazione), Registro Training, Scadenze, Notifiche e Dashboard.
- [2025-12-12 Communications Module](./devlog/2025-12-12-110000_communications_module.md) - **Completato** - [2025-12-12 Communications Module](./devlog/2025-12-12-110000_communications_module.md) - **Completato**
- [2025-12-12 Resend Integration](./devlog/2025-12-12-120000_resend_integration.md) - **Completato** - [2025-12-12 Resend Integration](./devlog/2025-12-12-120000_resend_integration.md) - **Completato**
- [2025-12-12 Magazzino: Categorie Gerarchiche](./devlog/2025-12-12-133000_remove_product_groups_add_categories.md) - **Completato** - [2025-12-12 Magazzino: Categorie Gerarchiche](./devlog/2025-12-12-133000_remove_product_groups_add_categories.md) - **Completato**

View File

@@ -60,14 +60,14 @@ Mapping delle funzionalità sui moduli esistenti:
- `TrainingForm`: Modale inserimento/modifica formazione (Caricamento file, calcolo automatico scadenza basato sul corso). - `TrainingForm`: Modale inserimento/modifica formazione (Caricamento file, calcolo automatico scadenza basato sul corso).
#### Integrazione Moduli Esistenti #### Integrazione Moduli Esistenti
- [ ] **Magazzino**: Gestione UI per Classificazioni a 3 livelli (Gruppo/Famiglia). - [x] **Magazzino**: Gestione UI per Classificazioni a 3 livelli (Gruppo/Famiglia). (Implementato selezione sottocategorie in RegistryPage)
- [x] **Magazzino**: Aggiungere campi Validità/Scadenza nel form Articolo. - [x] **Magazzino**: Aggiungere campi Validità/Scadenza nel form Articolo.
- [x] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti. - [x] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti.
- [x] **UI**: Aggiungere "Training" a `Sidebar.tsx` e `SearchBar.tsx`. - [x] **UI**: Aggiungere "Training" a `Sidebar.tsx` e `SearchBar.tsx`.
### 4. Workflow e Notifiche ### 4. Workflow e Notifiche
- [ ] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard. - [x] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard. (Aggiunto pulsante invio notifica)
- [ ] Integrazione con il Modulo Email per invio solleciti scadenze. - [x] Integrazione con il Modulo Email per invio solleciti scadenze.
### 5. Verifica e Test ### 5. Verifica e Test
- [ ] Test flusso completo: - [ ] Test flusso completo:
@@ -79,5 +79,7 @@ Mapping delle funzionalità sui moduli esistenti:
## Stato Attuale ## Stato Attuale
- Implementazione Core (Backend/Frontend) completata. - Implementazione Core (Backend/Frontend) completata.
- Da completare integrazione fine UI e workflow notifiche. - Integrazione Modulo Comunicazioni completata (Controllo attivazione app + invio email).
- 2025-12-12-174800_rimosse_tab_interne_modulo_formazione: Rimosse le tab interne (Dashboard, Registry, Matrix) dal layout del modulo Formazione in quanto ridondanti rispetto alla navigazione principale. - 2025-12-12-174800_rimosse_tab_interne_modulo_formazione: Rimosse le tab interne (Dashboard, Registry, Matrix) dal layout del modulo Formazione in quanto ridondanti rispetto alla navigazione principale.
- 2025-12-12-185000_integrazione_comunicazioni_formazione: Implementata integrazione formale con modulo Comunicazioni (Check AppService + logging).
- 2025-12-12-190500_fix_seed_db: Risolto bug mancata creazione categoria "Formazione" (TRAIN) nel seed del database per database esistenti.

View File

@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Zentral.Domain.Entities.Training; using Zentral.Domain.Entities.Training;
using Zentral.Domain.Interfaces;
using Zentral.Infrastructure.Data; using Zentral.Infrastructure.Data;
using Zentral.API.Services;
namespace Zentral.API.Modules.Training.Controllers; namespace Zentral.API.Modules.Training.Controllers;
@@ -10,10 +12,14 @@ namespace Zentral.API.Modules.Training.Controllers;
public class TrainingController : ControllerBase public class TrainingController : ControllerBase
{ {
private readonly ZentralDbContext _context; private readonly ZentralDbContext _context;
private readonly IEmailSender _emailSender;
private readonly AppService _appService;
public TrainingController(ZentralDbContext context) public TrainingController(ZentralDbContext context, IEmailSender emailSender, AppService appService)
{ {
_context = context; _context = context;
_emailSender = emailSender;
_appService = appService;
} }
[HttpGet] [HttpGet]
@@ -160,4 +166,44 @@ public class TrainingController : ControllerBase
return Ok(new { url = training.AttestatoUrl }); return Ok(new { url = training.AttestatoUrl });
} }
[HttpPost("{id}/notify")]
public async Task<IActionResult> SendNotification(int id)
{
if (!await _appService.IsAppEnabledAsync("communications"))
return BadRequest(new { message = "Il modulo Comunicazioni non è attivo. Impossibile inviare email." });
var training = await _context.TrainingRecords
.Include(t => t.ClienteContatto)
.Include(t => t.Articolo)
.FirstOrDefaultAsync(t => t.Id == id);
if (training == null)
return NotFound();
var emailSubject = $"Scadenza Formazione: {training.Articolo?.Descrizione}";
var emailBody = $@"
<h3>Avviso Scadenza Formazione</h3>
<p>Gentile {training.ClienteContatto?.Nome} {training.ClienteContatto?.Cognome},</p>
<p>Si ricorda che la formazione <strong>{training.Articolo?.Descrizione}</strong> effettuata il {training.DataEsecuzione:dd/MM/yyyy} è in scadenza il <strong>{training.DataScadenza:dd/MM/yyyy}</strong>.</p>
<p>Si prega di provvedere al rinnovo.</p>
<br>
<p>Cordiali saluti,<br>Team Formazione</p>
";
if (!string.IsNullOrEmpty(training.ClienteContatto?.Email))
{
try
{
await _emailSender.SendEmailAsync(training.ClienteContatto.Email, emailSubject, emailBody);
return Ok(new { message = $"Notifica inviata a {training.ClienteContatto.Email}" });
}
catch (Exception ex)
{
return BadRequest(new { message = $"Errore invio email: {ex.Message}" });
}
}
return BadRequest(new { message = "Email contatto non presente" });
}
} }

View File

@@ -7,7 +7,8 @@ public static class DbSeeder
{ {
public static void Seed(ZentralDbContext context) public static void Seed(ZentralDbContext context)
{ {
if (context.TipiPasto.Any()) return; if (!context.TipiPasto.Any())
{
// Tipi Pasto // Tipi Pasto
var tipiPasto = new List<TipoPasto> var tipiPasto = new List<TipoPasto>
@@ -231,7 +232,22 @@ public static class DbSeeder
new() { Id = 4, Username = "operatore", Nome = "Operatore", Ruolo = "Operatore" } new() { Id = 4, Username = "operatore", Nome = "Operatore", Ruolo = "Operatore" }
}; };
context.Utenti.AddRange(utenti); context.Utenti.AddRange(utenti);
context.SaveChanges(); context.SaveChanges();
}
// Ensure TRAIN category exists
if (!context.CodiciCategoria.Any(c => c.Codice == "TRAIN"))
{
context.CodiciCategoria.Add(new CodiceCategoria
{
Codice = "TRAIN",
Descrizione = "Formazione",
CoeffA = 1.0m,
CoeffB = 1.0m,
CoeffS = 1.0m
});
context.SaveChanges();
}
// Apps // Apps
if (!context.Apps.Any()) if (!context.Apps.Any())

View File

@@ -30,7 +30,8 @@
"preview": "Anteprima", "preview": "Anteprima",
"none": "Nessuno", "none": "Nessuno",
"view": "Dettaglio", "view": "Dettaglio",
"copy": "Copia" "copy": "Copia",
"category": "Categoria"
}, },
"menu": { "menu": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -1747,6 +1748,10 @@
"course": "Corso", "course": "Corso",
"deleteConfirm": "Eliminare questa formazione?", "deleteConfirm": "Eliminare questa formazione?",
"daysRemaining": "Giorni rimanenti", "daysRemaining": "Giorni rimanenti",
"expiringIn": "Scade tra {{days}} giorni" "expiringIn": "Scade tra {{days}} giorni",
"sendNotification": "Invia Notifica",
"notificationSent": "Notifica inviata con successo",
"editCourse": "Modifica Corso",
"editTraining": "Modifica Formazione"
} }
} }

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { import {
Box, Box,
Typography, Typography,
@@ -7,7 +7,10 @@ import {
Chip, Chip,
Paper, Paper,
Grid, Grid,
IconButton,
Tooltip
} from "@mui/material"; } from "@mui/material";
import { Send as SendIcon } from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { trainingService } from "../services/trainingService"; import { trainingService } from "../services/trainingService";
@@ -20,6 +23,13 @@ export default function DashboardPage() {
queryFn: () => trainingService.getExpiring(), queryFn: () => trainingService.getExpiring(),
}); });
const notifyMutation = useMutation({
mutationFn: (id: number) => trainingService.sendNotification(id),
onSuccess: () => {
alert(t("training.notificationSent"));
}
});
const expiredCount = expiringRecords.filter((r: any) => r.stato === 2).length; const expiredCount = expiringRecords.filter((r: any) => r.stato === 2).length;
const expiringCount = expiringRecords.filter((r: any) => r.stato === 1).length; const expiringCount = expiringRecords.filter((r: any) => r.stato === 1).length;
@@ -36,6 +46,22 @@ export default function DashboardPage() {
? <Chip label={t("training.expired")} color="error" size="small" /> ? <Chip label={t("training.expired")} color="error" size="small" />
: <Chip label={t("training.expiring")} color="warning" size="small" /> : <Chip label={t("training.expiring")} color="warning" size="small" />
) )
},
{
field: "actions",
headerName: t("common.actions"),
width: 100,
renderCell: (params: any) => (
<Tooltip title={t("training.sendNotification")}>
<IconButton
size="small"
color="primary"
onClick={() => notifyMutation.mutate(params.row.id)}
>
<SendIcon />
</IconButton>
</Tooltip>
)
} }
]; ];

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Box, Box,
@@ -24,7 +24,7 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { trainingService } from "../services/trainingService"; import { trainingService } from "../services/trainingService";
import { TrainingRecord, ClienteContatto, Articolo } from "../../../types"; import { TrainingRecord, ClienteContatto, Articolo, TipoArticolo } from "../../../types";
import { lookupService, articoliService, clientiService } from "../../../services/lookupService"; import { lookupService, articoliService, clientiService } from "../../../services/lookupService";
export default function MatrixPage() { export default function MatrixPage() {
@@ -47,19 +47,10 @@ export default function MatrixPage() {
queryFn: () => lookupService.getClienti(), queryFn: () => lookupService.getClienti(),
}); });
const { data: categories = [] } = useQuery({ /* Removed unused trainingCategoryId logic */
queryKey: ["categorie"],
queryFn: () => lookupService.getCategorie(),
});
const trainingCategoryId = useMemo(() => {
return categories.find(c => c.codice === "TRAIN")?.id;
}, [categories]);
const { data: courses = [] } = useQuery({ const { data: courses = [] } = useQuery({
queryKey: ["articles", "training", trainingCategoryId], queryKey: ["articles", "training"],
queryFn: () => trainingCategoryId ? articoliService.getAll({ categoriaId: trainingCategoryId }) : [], queryFn: () => articoliService.getAll({ tipo: TipoArticolo.Corso }),
enabled: !!trainingCategoryId,
}); });
const { data: contacts = [] } = useQuery({ const { data: contacts = [] } = useQuery({

View File

@@ -19,8 +19,11 @@ import {
Delete as DeleteIcon, Delete as DeleteIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { articoliService, lookupService } from "../../../services/lookupService"; import { articoliService } from "../../../services/lookupService";
import { Articolo, LookupItem, TipoArticolo } from "../../../types"; import { categoryService } from "../../warehouse/services/warehouseService";
import { Articolo, TipoArticolo } from "../../../types";
import { MenuItem } from "@mui/material";
export default function RegistryPage() { export default function RegistryPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,31 +35,47 @@ export default function RegistryPage() {
tipo: TipoArticolo.Corso tipo: TipoArticolo.Corso
}); });
// 1. Fetch Categories to find "TRAIN" (Formazione) // 1. Fetch Request ALL Categories
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ["categorie"], queryKey: ["warehouse-categories"],
queryFn: () => lookupService.getCategorie(), queryFn: () => categoryService.getAll(false),
}); });
const trainingCategoryId = useMemo(() => { const trainingCategoryId = useMemo(() => {
return categories.find((c: LookupItem) => c.codice === "TRAIN")?.id; return categories.find((c: any) => c.code === "TRAIN")?.id;
}, [categories]); }, [categories]);
// 2. Fetch Articles filtered by Training Category AND TipoArticolo.Corso // Find all descendants of TRAIN
const allowedCategories = useMemo(() => {
if (!trainingCategoryId) return [];
const descendants: any[] = [];
const queue = [trainingCategoryId];
while (queue.length > 0) {
const parentId = queue.shift();
const children = categories.filter((c: any) => c.parentCategoryId === parentId);
children.forEach((c: any) => {
descendants.push(c);
queue.push(c.id);
});
}
// Include TRAIN itself? Maybe better to force using subcategories if they exist,
// but allowing TRAIN is flexible.
const root = categories.find((c: any) => c.id === trainingCategoryId);
return root ? [root, ...descendants] : descendants;
}, [categories, trainingCategoryId]);
// 2. Fetch Articles filtered by TipoArticolo.Corso (ignore category filter for list to show all)
const { data: articles = [], isLoading } = useQuery({ const { data: articles = [], isLoading } = useQuery({
queryKey: ["articles", "training", trainingCategoryId], queryKey: ["articles", "training"],
queryFn: () => { queryFn: () => {
// Even if categories not waiting, we can filter by Tipo // We explicitly want ALL courses, regardless of subcategory
// But we prefer having both if possible.
// If trainingCategoryId is missing but we want to show list based on Type, we can do it.
// Let's pass trainingCategoryId if exists.
const params: any = { tipo: TipoArticolo.Corso }; const params: any = { tipo: TipoArticolo.Corso };
if (trainingCategoryId) params.categoriaId = trainingCategoryId;
return articoliService.getAll(params); return articoliService.getAll(params);
}, },
enabled: true, // Always enabled so we can see list by Type even if category missing (or maybe not?)
}); });
const createMutation = useMutation({ const createMutation = useMutation({
@@ -110,7 +129,7 @@ export default function RegistryPage() {
const dataToSave = { const dataToSave = {
...formData, ...formData,
categoriaId: trainingCategoryId, categoriaId: formData.categoriaId || trainingCategoryId, // Use selected or default to TRAIN
tipoMaterialeId: 1, // Default Material Type ID tipoMaterialeId: 1, // Default Material Type ID
unitaMisura: "H", // Hours unitaMisura: "H", // Hours
tipo: TipoArticolo.Corso, // Force Type tipo: TipoArticolo.Corso, // Force Type
@@ -206,6 +225,21 @@ export default function RegistryPage() {
fullWidth fullWidth
required required
/> />
<TextField
select
label={t("common.category")}
value={formData.categoriaId || trainingCategoryId || ""}
onChange={(e) => setFormData({ ...formData, categoriaId: Number(e.target.value) })}
fullWidth
>
{allowedCategories.map((c: any) => (
<MenuItem key={c.id} value={c.id}>
{c.name} ({c.code})
</MenuItem>
))}
</TextField>
<TextField <TextField
label={t("training.validityDays")} label={t("training.validityDays")}
type="number" type="number"

View File

@@ -26,5 +26,8 @@ export const trainingService = {
getExpiring: async () => { getExpiring: async () => {
const { data } = await api.get<TrainingRecord[]>("/training/expiring"); const { data } = await api.get<TrainingRecord[]>("/training/expiring");
return data; return data;
},
sendNotification: async (id: number) => {
await api.post(`/training/${id}/notify`);
} }
}; };