feat: Implement training record notification system with UI and backend email integration, and ensure 'TRAIN' category seeding.
This commit is contained in:
@@ -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.
|
||||
- [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**
|
||||
- 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 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**
|
||||
|
||||
@@ -60,14 +60,14 @@ Mapping delle funzionalità sui moduli esistenti:
|
||||
- `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).
|
||||
- [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] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti.
|
||||
- [x] **UI**: Aggiungere "Training" a `Sidebar.tsx` e `SearchBar.tsx`.
|
||||
|
||||
### 4. Workflow e Notifiche
|
||||
- [ ] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard.
|
||||
- [ ] Integrazione con il Modulo Email per invio solleciti scadenze.
|
||||
- [x] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard. (Aggiunto pulsante invio notifica)
|
||||
- [x] Integrazione con il Modulo Email per invio solleciti scadenze.
|
||||
|
||||
### 5. Verifica e Test
|
||||
- [ ] Test flusso completo:
|
||||
@@ -79,5 +79,7 @@ Mapping delle funzionalità sui moduli esistenti:
|
||||
|
||||
## Stato Attuale
|
||||
- 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-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.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.Domain.Entities.Training;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Zentral.API.Services;
|
||||
|
||||
namespace Zentral.API.Modules.Training.Controllers;
|
||||
|
||||
@@ -10,10 +12,14 @@ namespace Zentral.API.Modules.Training.Controllers;
|
||||
public class TrainingController : ControllerBase
|
||||
{
|
||||
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;
|
||||
_emailSender = emailSender;
|
||||
_appService = appService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -160,4 +166,44 @@ public class TrainingController : ControllerBase
|
||||
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ public static class DbSeeder
|
||||
{
|
||||
public static void Seed(ZentralDbContext context)
|
||||
{
|
||||
if (context.TipiPasto.Any()) return;
|
||||
if (!context.TipiPasto.Any())
|
||||
{
|
||||
|
||||
// Tipi Pasto
|
||||
var tipiPasto = new List<TipoPasto>
|
||||
@@ -232,6 +233,21 @@ public static class DbSeeder
|
||||
};
|
||||
context.Utenti.AddRange(utenti);
|
||||
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
|
||||
if (!context.Apps.Any())
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"preview": "Anteprima",
|
||||
"none": "Nessuno",
|
||||
"view": "Dettaglio",
|
||||
"copy": "Copia"
|
||||
"copy": "Copia",
|
||||
"category": "Categoria"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -1747,6 +1748,10 @@
|
||||
"course": "Corso",
|
||||
"deleteConfirm": "Eliminare questa formazione?",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
Chip,
|
||||
Paper,
|
||||
Grid,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from "@mui/material";
|
||||
import { Send as SendIcon } from "@mui/icons-material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { trainingService } from "../services/trainingService";
|
||||
@@ -20,6 +23,13 @@ export default function DashboardPage() {
|
||||
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 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.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>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
|
||||
export default function MatrixPage() {
|
||||
@@ -47,19 +47,10 @@ export default function MatrixPage() {
|
||||
queryFn: () => lookupService.getClienti(),
|
||||
});
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["categorie"],
|
||||
queryFn: () => lookupService.getCategorie(),
|
||||
});
|
||||
|
||||
const trainingCategoryId = useMemo(() => {
|
||||
return categories.find(c => c.codice === "TRAIN")?.id;
|
||||
}, [categories]);
|
||||
|
||||
/* Removed unused trainingCategoryId logic */
|
||||
const { data: courses = [] } = useQuery({
|
||||
queryKey: ["articles", "training", trainingCategoryId],
|
||||
queryFn: () => trainingCategoryId ? articoliService.getAll({ categoriaId: trainingCategoryId }) : [],
|
||||
enabled: !!trainingCategoryId,
|
||||
queryKey: ["articles", "training"],
|
||||
queryFn: () => articoliService.getAll({ tipo: TipoArticolo.Corso }),
|
||||
});
|
||||
|
||||
const { data: contacts = [] } = useQuery({
|
||||
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { articoliService, lookupService } from "../../../services/lookupService";
|
||||
import { Articolo, LookupItem, TipoArticolo } from "../../../types";
|
||||
import { articoliService } from "../../../services/lookupService";
|
||||
import { categoryService } from "../../warehouse/services/warehouseService";
|
||||
import { Articolo, TipoArticolo } from "../../../types";
|
||||
import { MenuItem } from "@mui/material";
|
||||
|
||||
|
||||
export default function RegistryPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,31 +35,47 @@ export default function RegistryPage() {
|
||||
tipo: TipoArticolo.Corso
|
||||
});
|
||||
|
||||
// 1. Fetch Categories to find "TRAIN" (Formazione)
|
||||
// 1. Fetch Request ALL Categories
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["categorie"],
|
||||
queryFn: () => lookupService.getCategorie(),
|
||||
queryKey: ["warehouse-categories"],
|
||||
queryFn: () => categoryService.getAll(false),
|
||||
});
|
||||
|
||||
const trainingCategoryId = useMemo(() => {
|
||||
return categories.find((c: LookupItem) => c.codice === "TRAIN")?.id;
|
||||
return categories.find((c: any) => c.code === "TRAIN")?.id;
|
||||
}, [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({
|
||||
queryKey: ["articles", "training", trainingCategoryId],
|
||||
queryKey: ["articles", "training"],
|
||||
queryFn: () => {
|
||||
// Even if categories not waiting, we can filter by Tipo
|
||||
// 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.
|
||||
|
||||
// We explicitly want ALL courses, regardless of subcategory
|
||||
const params: any = { tipo: TipoArticolo.Corso };
|
||||
if (trainingCategoryId) params.categoriaId = trainingCategoryId;
|
||||
|
||||
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({
|
||||
@@ -110,7 +129,7 @@ export default function RegistryPage() {
|
||||
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
categoriaId: trainingCategoryId,
|
||||
categoriaId: formData.categoriaId || trainingCategoryId, // Use selected or default to TRAIN
|
||||
tipoMaterialeId: 1, // Default Material Type ID
|
||||
unitaMisura: "H", // Hours
|
||||
tipo: TipoArticolo.Corso, // Force Type
|
||||
@@ -206,6 +225,21 @@ export default function RegistryPage() {
|
||||
fullWidth
|
||||
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
|
||||
label={t("training.validityDays")}
|
||||
type="number"
|
||||
|
||||
@@ -26,5 +26,8 @@ export const trainingService = {
|
||||
getExpiring: async () => {
|
||||
const { data } = await api.get<TrainingRecord[]>("/training/expiring");
|
||||
return data;
|
||||
},
|
||||
sendNotification: async (id: number) => {
|
||||
await api.post(`/training/${id}/notify`);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user