Compare commits
4 Commits
64d93a936c
...
v1.0.0-obi
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f954f494 | |||
| 99ce5e1e6a | |||
| 4810d49410 | |||
| 49abef6f96 |
@@ -2,6 +2,8 @@
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Produci sempre prima il piano di implementazione nelle cartelle dedicate e proponi di default all'utente di visionarlo, se l'utente specifica di voler andare avanti, prosegui con l'implementazione del piano senza fermarti; aggiorna il piano man mano che viene sviluppato.
|
||||
|
||||
Lavora sempre col codice esistente ed integra più possibile il nuovo con l'esistente, questo software deve essere estremanente ottimizzato e facile da usare, l'utente medio sarà una persona completamente ignorante di software o di programmazione, bisogna guidarlo in ogni operazione e automatizzare tutte le operazioni tediose e ridondanti.
|
||||
|
||||
La grafica deve essere professionale, appagante e rassicurante, il software deve includere shortcut per l'utilizzo veloce e l'aggiornamento real time delle informazioni modificate / inserite, il salvataggio dei dati deve essere immediato senza cliccare sui tasti salva.
|
||||
|
||||
@@ -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**
|
||||
- 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**
|
||||
- Ristrutturazione interfaccia: Sidebar a 2 livelli, Tabs, SearchBar.
|
||||
- [2025-12-03 Backend Fix](./devlog/2025-12-03_backend_fix.md) - **Completato**
|
||||
@@ -49,6 +52,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**
|
||||
|
||||
@@ -6,8 +6,10 @@ Creare un modulo generale per la gestione della formazione (Training), permetten
|
||||
## 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").
|
||||
- Viene introdotta una **Classificazione Specifica** tramite property `Tipo` (`Standard`, `Corso`, `Servizio`).
|
||||
- I Corsi saranno `Articolo` con `Tipo = Corso`.
|
||||
- La `Categoria` (Merceologica) sarà usata per il raggruppamento (es. "Sicurezza", "IT").
|
||||
- Il campo `GiorniValidita` gestirà la durata della validità dell'attestato.
|
||||
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.
|
||||
@@ -15,34 +17,34 @@ Mapping delle funzionalità sui moduli esistenti:
|
||||
## Piano di Lavoro
|
||||
|
||||
### 1. Documentazione e Analisi
|
||||
- [ ] Creazione piano di lavoro (questo file).
|
||||
- [ ] Aggiornamento `ZENTRAL.md`.
|
||||
- [x] Creazione piano di lavoro (questo file).
|
||||
- [x] Aggiornamento `ZENTRAL.md`.
|
||||
|
||||
### 2. Backend (.NET)
|
||||
#### Domain Layer
|
||||
- [ ] **Refactoring Categorie (Warehouse)**:
|
||||
- [x] **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`**:
|
||||
- [x] **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`**:
|
||||
- [x] **Nuova Entity `ClienteContatto`**:
|
||||
- Proprietà: `Nome`, `Cognome`, `Email`, `Ruolo`, `Telefono`, foreign key a `Cliente`.
|
||||
- Aggiornare `Cliente` con collection `Contatti`.
|
||||
- [ ] **Nuova Entity `TrainingRecord`**:
|
||||
- [x] **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`.
|
||||
- [x] Creare Migrazione EF per le nuove entità e modifiche.
|
||||
- [x] 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`**:
|
||||
- [x] **Aggiornare `ArticoliController`**: Gestione nuovi campi (Validità, Categorie).
|
||||
- [x] **Gestione Classificazioni**: Implementare API per gestire la gerarchia (o livelli) delle categorie merceologiche.
|
||||
- [x] **Aggiornare `ClientiController`**: Gestione CRUD Contatti.
|
||||
- [x] **Nuovo `TrainingController`**:
|
||||
- CRUD TrainingRecords.
|
||||
- Upload file attestato.
|
||||
- Endpoint `GetExpiringTrainings` per la dashboard (filtri per data, azienda, categoria corso).
|
||||
@@ -50,21 +52,22 @@ Mapping delle funzionalità sui moduli esistenti:
|
||||
|
||||
### 3. Frontend (React)
|
||||
#### Modulo Training (Nuova App `training`)
|
||||
- [ ] **Setup Modulo**: Creare cartella `src/frontend/src/apps/training` e configurare route.
|
||||
- [ ] **Componenti**:
|
||||
- [x] **Setup Modulo**: Creare cartella `src/frontend/src/apps/training` e configurare route.
|
||||
- [x] **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.
|
||||
- [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:
|
||||
@@ -75,4 +78,8 @@ Mapping delle funzionalità sui moduli esistenti:
|
||||
5. Verifica Scadenza e Notifica.
|
||||
|
||||
## Stato Attuale
|
||||
- Inizio analisi e setup.
|
||||
- Implementazione Core (Backend/Frontend) completata.
|
||||
- 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.
|
||||
|
||||
@@ -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).
|
||||
@@ -24,7 +24,8 @@ public class ArticoliController : ControllerBase
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] int? tipoMaterialeId,
|
||||
[FromQuery] int? categoriaId,
|
||||
[FromQuery] bool? attivo)
|
||||
[FromQuery] bool? attivo,
|
||||
[FromQuery] TipoArticolo? tipo)
|
||||
{
|
||||
var query = _context.Articoli
|
||||
.Include(a => a.TipoMateriale)
|
||||
@@ -43,6 +44,9 @@ public class ArticoliController : ControllerBase
|
||||
if (attivo.HasValue)
|
||||
query = query.Where(a => a.Attivo == attivo.Value);
|
||||
|
||||
if (tipo.HasValue)
|
||||
query = query.Where(a => a.Tipo == tipo.Value);
|
||||
|
||||
return await query.OrderBy(a => a.Descrizione).ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public class ClientiController : ControllerBase
|
||||
{
|
||||
var cliente = await _context.Clienti
|
||||
.Include(c => c.Eventi)
|
||||
.Include(c => c.Contatti)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (cliente == null)
|
||||
@@ -99,4 +100,53 @@ public class ClientiController : ControllerBase
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Contatti Management
|
||||
[HttpGet("{id}/contatti")]
|
||||
public async Task<ActionResult<IEnumerable<ClienteContatto>>> GetContatti(int id)
|
||||
{
|
||||
var contatti = await _context.Contatti
|
||||
.Where(c => c.ClienteId == id)
|
||||
.OrderBy(c => c.Cognome).ThenBy(c => c.Nome)
|
||||
.ToListAsync();
|
||||
return contatti;
|
||||
}
|
||||
|
||||
[HttpPost("{id}/contatti")]
|
||||
public async Task<ActionResult<ClienteContatto>> CreateContatto(int id, ClienteContatto contatto)
|
||||
{
|
||||
if (id != contatto.ClienteId)
|
||||
contatto.ClienteId = id;
|
||||
|
||||
contatto.CreatedAt = DateTime.UtcNow;
|
||||
_context.Contatti.Add(contatto);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(contatto);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/contatti/{contattoId}")]
|
||||
public async Task<IActionResult> UpdateContatto(int id, int contattoId, ClienteContatto contatto)
|
||||
{
|
||||
if (id != contatto.ClienteId || contattoId != contatto.Id)
|
||||
return BadRequest();
|
||||
|
||||
contatto.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(contatto).State = EntityState.Modified;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/contatti/{contattoId}")]
|
||||
public async Task<IActionResult> DeleteContatto(int id, int contattoId)
|
||||
{
|
||||
var contatto = await _context.Contatti.FindAsync(contattoId);
|
||||
if (contatto == null || contatto.ClienteId != id)
|
||||
return NotFound();
|
||||
|
||||
_context.Contatti.Remove(contatto);
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/training")]
|
||||
public class TrainingController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly AppService _appService;
|
||||
|
||||
public TrainingController(ZentralDbContext context, IEmailSender emailSender, AppService appService)
|
||||
{
|
||||
_context = context;
|
||||
_emailSender = emailSender;
|
||||
_appService = appService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<TrainingRecord>>> GetTrainings(
|
||||
[FromQuery] int? clienteId,
|
||||
[FromQuery] int? articoloId,
|
||||
[FromQuery] bool? expiring)
|
||||
{
|
||||
var query = _context.TrainingRecords
|
||||
.Include(t => t.ClienteContatto)
|
||||
.ThenInclude(cc => cc.Cliente)
|
||||
.Include(t => t.Articolo)
|
||||
.AsQueryable();
|
||||
|
||||
if (clienteId.HasValue)
|
||||
query = query.Where(t => t.ClienteContatto.ClienteId == clienteId);
|
||||
|
||||
if (articoloId.HasValue)
|
||||
query = query.Where(t => t.ArticoloId == articoloId);
|
||||
|
||||
if (expiring.HasValue && expiring.Value)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var threshold = today.AddDays(30);
|
||||
query = query.Where(t => t.DataScadenza != null && t.DataScadenza <= threshold && t.DataScadenza >= today);
|
||||
}
|
||||
|
||||
return await query.OrderBy(t => t.DataScadenza).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<TrainingRecord>> GetTraining(int id)
|
||||
{
|
||||
var training = await _context.TrainingRecords
|
||||
.Include(t => t.ClienteContatto)
|
||||
.Include(t => t.Articolo)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
if (training == null)
|
||||
return NotFound();
|
||||
|
||||
return training;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<TrainingRecord>> CreateTraining(TrainingRecord training)
|
||||
{
|
||||
// Calculate expiration if needed logic suggests it, but usually passed by frontend or computed from course validity
|
||||
// If DataScadenza is null, try to calculate from Articolo
|
||||
|
||||
if (training.DataScadenza == null)
|
||||
{
|
||||
var articolo = await _context.Articoli.FindAsync(training.ArticoloId);
|
||||
if (articolo != null && articolo.GiorniValidita.HasValue)
|
||||
{
|
||||
training.DataScadenza = training.DataEsecuzione.AddDays(articolo.GiorniValidita.Value);
|
||||
}
|
||||
}
|
||||
|
||||
training.CreatedAt = DateTime.UtcNow;
|
||||
_context.TrainingRecords.Add(training);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetTraining), new { id = training.Id }, training);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateTraining(int id, TrainingRecord training)
|
||||
{
|
||||
if (id != training.Id)
|
||||
return BadRequest();
|
||||
|
||||
training.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(training).State = EntityState.Modified;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.TrainingRecords.AnyAsync(e => e.Id == id))
|
||||
return NotFound();
|
||||
throw;
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteTraining(int id)
|
||||
{
|
||||
var training = await _context.TrainingRecords.FindAsync(id);
|
||||
if (training == null)
|
||||
return NotFound();
|
||||
|
||||
_context.TrainingRecords.Remove(training);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("expiring")]
|
||||
public async Task<ActionResult<IEnumerable<TrainingRecord>>> GetExpiringTrainings()
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var threshold = today.AddDays(30);
|
||||
|
||||
// Return Expired ( < today) OR Expiring Soon ( between today and threshold )
|
||||
var records = await _context.TrainingRecords
|
||||
.Include(t => t.ClienteContatto)
|
||||
.Include(t => t.Articolo)
|
||||
.Where(t => t.DataScadenza != null && (t.DataScadenza <= threshold))
|
||||
.OrderBy(t => t.DataScadenza)
|
||||
.ToListAsync();
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
[HttpPost("{id}/attestato")]
|
||||
public async Task<IActionResult> UploadAttestato(int id, IFormFile file)
|
||||
{
|
||||
var training = await _context.TrainingRecords.FindAsync(id);
|
||||
if (training == null)
|
||||
return NotFound();
|
||||
|
||||
// Save file logic - For now saving to wwwroot/uploads or similar, or just keeping URL if using external storage
|
||||
// Assuming simple local storage for now
|
||||
|
||||
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "training");
|
||||
if (!Directory.Exists(uploadsFolder))
|
||||
Directory.CreateDirectory(uploadsFolder);
|
||||
|
||||
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
|
||||
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
training.AttestatoUrl = $"/uploads/training/{fileName}";
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
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" });
|
||||
}
|
||||
}
|
||||
@@ -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<IMrpService, MrpService>();
|
||||
|
||||
// Training Module Services
|
||||
builder.Services.AddScoped<Zentral.API.Modules.Training.Services.TrainingNotificationService>();
|
||||
|
||||
// Memory cache for module state
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
|
||||
@@ -535,6 +535,20 @@ public class AppService
|
||||
RoutePath = "/communications",
|
||||
IsAvailable = true,
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,8 +24,21 @@ public class Articolo : BaseEntity
|
||||
public string? MimeType { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public bool Attivo { get; set; } = true;
|
||||
public int? GiorniValidita { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Classificazione specifica dell'articolo (Standard, Corso, Servizio)
|
||||
/// </summary>
|
||||
public TipoArticolo Tipo { get; set; } = TipoArticolo.Standard;
|
||||
|
||||
public TipoMateriale? TipoMateriale { get; set; }
|
||||
public CodiceCategoria? Categoria { get; set; }
|
||||
public ICollection<EventoDettaglioPrelievo> DettagliPrelievo { get; set; } = new List<EventoDettaglioPrelievo>();
|
||||
}
|
||||
|
||||
public enum TipoArticolo
|
||||
{
|
||||
Standard = 0,
|
||||
Corso = 1,
|
||||
Servizio = 2
|
||||
}
|
||||
|
||||
@@ -28,4 +28,5 @@ public class Cliente : BaseEntity
|
||||
|
||||
public ICollection<Evento> Eventi { get; set; } = new List<Evento>();
|
||||
public ICollection<Zentral.Domain.Entities.Sales.SalesOrder> SalesOrders { get; set; } = new List<Zentral.Domain.Entities.Sales.SalesOrder>();
|
||||
public ICollection<ClienteContatto> Contatti { get; set; } = new List<ClienteContatto>();
|
||||
}
|
||||
|
||||
12
src/backend/Zentral.Domain/Entities/ClienteContatto.cs
Normal file
12
src/backend/Zentral.Domain/Entities/ClienteContatto.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Zentral.Domain.Entities;
|
||||
|
||||
public class ClienteContatto : BaseEntity
|
||||
{
|
||||
public string Nome { get; set; } = string.Empty;
|
||||
public string Cognome { get; set; } = string.Empty;
|
||||
public string? Email { get; set; }
|
||||
public string? Ruolo { get; set; }
|
||||
public string? Telefono { get; set; }
|
||||
public int ClienteId { get; set; }
|
||||
public Cliente Cliente { get; set; } = null!;
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Zentral.Domain.Entities.Training;
|
||||
|
||||
public enum TrainingStatus
|
||||
{
|
||||
Valid,
|
||||
Expiring,
|
||||
Expired
|
||||
}
|
||||
|
||||
public class TrainingRecord : BaseEntity
|
||||
{
|
||||
public int ClienteContattoId { get; set; }
|
||||
public ClienteContatto ClienteContatto { get; set; } = null!;
|
||||
|
||||
public int ArticoloId { get; set; }
|
||||
public Articolo Articolo { get; set; } = null!;
|
||||
|
||||
public DateTime DataEsecuzione { get; set; }
|
||||
public DateTime? DataScadenza { get; set; }
|
||||
|
||||
public string? AttestatoUrl { get; set; }
|
||||
public string? Note { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public TrainingStatus Stato
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!DataScadenza.HasValue) return TrainingStatus.Valid;
|
||||
var days = (DataScadenza.Value - DateTime.Today).TotalDays;
|
||||
if (days < 0) return TrainingStatus.Expired;
|
||||
if (days <= 30) return TrainingStatus.Expiring; // Configurable ideally
|
||||
return TrainingStatus.Valid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -72,7 +73,8 @@ public static class DbSeeder
|
||||
new() { Id = 1, Codice = "A", Descrizione = "Per Adulti", CoeffA = 1.0m, CoeffB = 0.5m, CoeffS = 1.0m },
|
||||
new() { Id = 2, Codice = "B", Descrizione = "Per Buffet", CoeffA = 0.8m, CoeffB = 1.0m, CoeffS = 0.8m },
|
||||
new() { Id = 3, Codice = "S", Descrizione = "Per Seduti", CoeffA = 1.0m, CoeffB = 0.6m, CoeffS = 1.0m },
|
||||
new() { Id = 4, Codice = "U", Descrizione = "Universale", CoeffA = 1.0m, CoeffB = 1.0m, CoeffS = 1.0m }
|
||||
new() { Id = 4, Codice = "U", Descrizione = "Universale", CoeffA = 1.0m, CoeffB = 1.0m, CoeffS = 1.0m },
|
||||
new() { Id = 5, Codice = "TRAIN", Descrizione = "Formazione", CoeffA = 1.0m, CoeffB = 1.0m, CoeffS = 1.0m }
|
||||
};
|
||||
context.CodiciCategoria.AddRange(categorie);
|
||||
|
||||
@@ -230,7 +232,78 @@ public static class DbSeeder
|
||||
new() { Id = 4, Username = "operatore", Nome = "Operatore", Ruolo = "Operatore" }
|
||||
};
|
||||
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
|
||||
if (!context.Apps.Any())
|
||||
{
|
||||
var apps = new List<App>
|
||||
{
|
||||
new() { Code = "warehouse", Name = "Magazzino", Icon = "Warehouse", BasePrice = 100, RoutePath = "/warehouse", SortOrder = 10, Description = "Gestione completa del magazzino" },
|
||||
new() { Code = "purchases", Name = "Acquisti", Icon = "ShoppingCart", BasePrice = 80, RoutePath = "/purchases", SortOrder = 20, Description = "Gestione ciclo passivo e fornitori" },
|
||||
new() { Code = "sales", Name = "Vendite", Icon = "PointOfSale", BasePrice = 80, RoutePath = "/sales", SortOrder = 30, Description = "Gestione ciclo attivo e clienti" },
|
||||
new() { Code = "production", Name = "Produzione", Icon = "Factory", BasePrice = 150, RoutePath = "/production", SortOrder = 40, Description = "Gestione della produzione e MRP" },
|
||||
new() { Code = "events", Name = "Eventi", Icon = "Event", BasePrice = 120, RoutePath = "/events", SortOrder = 50, Description = "Gestione eventi e catering" },
|
||||
new() { Code = "hr", Name = "Personale", Icon = "People", BasePrice = 60, RoutePath = "/hr", SortOrder = 60, Description = "Gestione risorse umane" },
|
||||
new() { Code = "communications", Name = "Comunicazioni", Icon = "Email", BasePrice = 40, RoutePath = "/communications", SortOrder = 70, Description = "Gestione email e comunicazioni" },
|
||||
new() { Code = "report-designer", Name = "Report Designer", Icon = "Print", BasePrice = 50, RoutePath = "/report-designer", SortOrder = 80, Description = "Editor di report personalizzati" },
|
||||
new() { Code = "training", Name = "Formazione", Icon = "School", BasePrice = 50, RoutePath = "/training", SortOrder = 90, Description = "Gestione corsi e scadenze formazione" }
|
||||
};
|
||||
context.Apps.AddRange(apps);
|
||||
context.SaveChanges();
|
||||
|
||||
// Auto-subscribe all for demo/dev
|
||||
foreach (var app in apps)
|
||||
{
|
||||
context.AppSubscriptions.Add(new AppSubscription
|
||||
{
|
||||
AppId = app.Id,
|
||||
IsEnabled = true,
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddYears(1),
|
||||
SubscriptionType = SubscriptionType.Annual,
|
||||
AutoRenew = true,
|
||||
PaidPrice = app.BasePrice
|
||||
});
|
||||
}
|
||||
context.SaveChanges();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ensure Training exists if apps already seeded
|
||||
if (!context.Apps.Any(a => a.Code == "training"))
|
||||
{
|
||||
var trainingApp = new App { Code = "training", Name = "Formazione", Icon = "School", BasePrice = 50, RoutePath = "/training", SortOrder = 90, Description = "Gestione corsi e scadenze formazione" };
|
||||
context.Apps.Add(trainingApp);
|
||||
context.SaveChanges();
|
||||
|
||||
context.AppSubscriptions.Add(new AppSubscription
|
||||
{
|
||||
AppId = trainingApp.Id,
|
||||
IsEnabled = true,
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddYears(1),
|
||||
SubscriptionType = SubscriptionType.Annual,
|
||||
AutoRenew = true,
|
||||
PaidPrice = trainingApp.BasePrice
|
||||
});
|
||||
context.SaveChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using Zentral.Domain.Entities.Sales;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Zentral.Domain.Entities.HR;
|
||||
using Zentral.Domain.Entities.Communications;
|
||||
using Zentral.Domain.Entities.Training;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.Infrastructure.Data;
|
||||
@@ -99,6 +100,11 @@ public class ZentralDbContext : DbContext
|
||||
// Communications module entities
|
||||
public DbSet<EmailLog> EmailLogs => Set<EmailLog>();
|
||||
|
||||
// Training module entities
|
||||
public DbSet<ClienteContatto> Contatti => Set<ClienteContatto>();
|
||||
public DbSet<TrainingRecord> TrainingRecords => Set<TrainingRecord>();
|
||||
public DbSet<TrainingNotification> TrainingNotifications => Set<TrainingNotification>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -393,6 +399,35 @@ public class ZentralDbContext : DbContext
|
||||
entity.HasIndex(e => e.EntityName);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ClienteContatto
|
||||
modelBuilder.Entity<ClienteContatto>(entity =>
|
||||
{
|
||||
entity.ToTable("ClienteContatti");
|
||||
|
||||
entity.HasOne(e => e.Cliente)
|
||||
.WithMany(c => c.Contatti)
|
||||
.HasForeignKey(e => e.ClienteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// TrainingRecord
|
||||
modelBuilder.Entity<TrainingRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("TrainingRecords");
|
||||
|
||||
entity.HasOne(e => e.ClienteContatto)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ClienteContattoId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Articolo)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ArticoloId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ===============================================
|
||||
// WAREHOUSE MODULE ENTITIES
|
||||
// ===============================================
|
||||
|
||||
4892
src/backend/Zentral.Infrastructure/Migrations/20251212143625_AddTrainingModule.Designer.cs
generated
Normal file
4892
src/backend/Zentral.Infrastructure/Migrations/20251212143625_AddTrainingModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrainingModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GiorniValidita",
|
||||
table: "Articoli",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClienteContatti",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Nome = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Cognome = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Ruolo = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Telefono = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ClienteId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
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_ClienteContatti", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClienteContatti_Clienti_ClienteId",
|
||||
column: x => x.ClienteId,
|
||||
principalTable: "Clienti",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TrainingRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ClienteContattoId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ArticoloId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DataEsecuzione = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
DataScadenza = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
AttestatoUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Note = 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_TrainingRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrainingRecords_Articoli_ArticoloId",
|
||||
column: x => x.ArticoloId,
|
||||
principalTable: "Articoli",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrainingRecords_ClienteContatti_ClienteContattoId",
|
||||
column: x => x.ClienteContattoId,
|
||||
principalTable: "ClienteContatti",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClienteContatti_ClienteId",
|
||||
table: "ClienteContatti",
|
||||
column: "ClienteId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrainingRecords_ArticoloId",
|
||||
table: "TrainingRecords",
|
||||
column: "ArticoloId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrainingRecords_ClienteContattoId",
|
||||
table: "TrainingRecords",
|
||||
column: "ClienteContattoId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TrainingRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClienteContatti");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GiorniValidita",
|
||||
table: "Articoli");
|
||||
}
|
||||
}
|
||||
}
|
||||
4895
src/backend/Zentral.Infrastructure/Migrations/20251212170220_AddTipoArticolo.Designer.cs
generated
Normal file
4895
src/backend/Zentral.Infrastructure/Migrations/20251212170220_AddTipoArticolo.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTipoArticolo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Tipo",
|
||||
table: "Articoli",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Tipo",
|
||||
table: "Articoli");
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,9 @@ namespace Zentral.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("GiorniValidita")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte[]>("Immagine")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
@@ -195,6 +198,9 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.Property<decimal?>("QtaStdS")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Tipo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TipoMaterialeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -372,6 +378,54 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.ToTable("Clienti");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.ClienteContatto", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClienteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cognome")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomFieldsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nome")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Ruolo")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Telefono")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClienteId");
|
||||
|
||||
b.ToTable("ClienteContatti", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.CodiceCategoria", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2595,6 +2649,113 @@ namespace Zentral.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ArticoloId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AttestatoUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ClienteContattoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomFieldsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataEsecuzione")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DataScadenza")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ArticoloId");
|
||||
|
||||
b.HasIndex("ClienteContattoId");
|
||||
|
||||
b.ToTable("TrainingRecords", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.UserDashboardPreference", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3924,6 +4085,17 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.Navigation("TipoMateriale");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.ClienteContatto", b =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Cliente", "Cliente")
|
||||
.WithMany("Contatti")
|
||||
.HasForeignKey("ClienteId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Cliente");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Evento", b =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Cliente", "Cliente")
|
||||
@@ -4306,6 +4478,34 @@ namespace Zentral.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Articolo", "Articolo")
|
||||
.WithMany()
|
||||
.HasForeignKey("ArticoloId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Zentral.Domain.Entities.ClienteContatto", "ClienteContatto")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClienteContattoId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Articolo");
|
||||
|
||||
b.Navigation("ClienteContatto");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.UserDashboardPreference", b =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Utente", "Utente")
|
||||
@@ -4588,6 +4788,8 @@ namespace Zentral.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Cliente", b =>
|
||||
{
|
||||
b.Navigation("Contatti");
|
||||
|
||||
b.Navigation("Eventi");
|
||||
|
||||
b.Navigation("SalesOrders");
|
||||
|
||||
@@ -322,6 +322,16 @@
|
||||
"pagamenti": "Payments",
|
||||
"rimborsi": "Reimbursements"
|
||||
},
|
||||
"training": {
|
||||
"title": "Training Management",
|
||||
"dashboard": "Dashboard",
|
||||
"matrix": "Matrix",
|
||||
"registry": "Course Registry",
|
||||
"workers": "Workers",
|
||||
"deadlines": "Deadlines",
|
||||
"notifications": "Notifications",
|
||||
"dataExchange": "Import/Export"
|
||||
},
|
||||
"admin": {
|
||||
"title": "App Management",
|
||||
"subtitle": "Configure active apps and manage subscriptions",
|
||||
@@ -422,6 +432,14 @@
|
||||
"4": "Expense reports and reimbursements",
|
||||
"5": "Personnel cost analysis"
|
||||
},
|
||||
"training": {
|
||||
"0": "Course registry management",
|
||||
"1": "Participant registry",
|
||||
"2": "Expiry and renewal monitoring",
|
||||
"3": "Certificate archiving",
|
||||
"4": "Competence matrix",
|
||||
"5": "Automatic expiry notifications"
|
||||
},
|
||||
"default": "Complete app features"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"preview": "Anteprima",
|
||||
"none": "Nessuno",
|
||||
"view": "Dettaglio",
|
||||
"copy": "Copia"
|
||||
"copy": "Copia",
|
||||
"category": "Categoria"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -62,7 +63,8 @@
|
||||
"movements": "Movimenti",
|
||||
"stock": "Giacenze",
|
||||
"inventory": "Inventario",
|
||||
"categories": "Categorie"
|
||||
"categories": "Categorie",
|
||||
"training": "Formazione"
|
||||
},
|
||||
"navigation": {
|
||||
"searchPlaceholder": "Cerca...",
|
||||
@@ -209,6 +211,10 @@
|
||||
"pec": "PEC",
|
||||
"fiscalCode": "Codice Fiscale",
|
||||
"recipientCode": "Codice Destinatario",
|
||||
"contacts": "Contatti",
|
||||
"newContact": "Nuovo Contatto",
|
||||
"editContact": "Modifica Contatto",
|
||||
"role": "Ruolo",
|
||||
"generatedOnSave": "(Generato al salvataggio)",
|
||||
"autoGenerated": "Generato automaticamente",
|
||||
"willBeAssigned": "Verrà assegnato automaticamente",
|
||||
@@ -318,6 +324,16 @@
|
||||
"pagamenti": "Pagamenti",
|
||||
"rimborsi": "Rimborsi"
|
||||
},
|
||||
"training": {
|
||||
"title": "Gestione Formazione",
|
||||
"dashboard": "Dashboard",
|
||||
"matrix": "Matrice",
|
||||
"registry": "Anagrafica Corsi",
|
||||
"workers": "Lavoratori",
|
||||
"deadlines": "Scadenze",
|
||||
"notifications": "Notifiche",
|
||||
"dataExchange": "Import/Export"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Gestione Applicazioni",
|
||||
"subtitle": "Configura le applicazioni attive e gestisci le subscription",
|
||||
@@ -419,6 +435,14 @@
|
||||
"4": "Note spese e rimborsi",
|
||||
"5": "Analisi costi personale"
|
||||
},
|
||||
"training": {
|
||||
"0": "Gestione anagrafica corsi",
|
||||
"1": "Registro partecipanti",
|
||||
"2": "Monitoraggio scadenze e rinnovi",
|
||||
"3": "Archiviazione attestati",
|
||||
"4": "Matrice competenze",
|
||||
"5": "Notifiche automatiche scadenze"
|
||||
},
|
||||
"default": "Funzionalità complete dell'applicazione"
|
||||
}
|
||||
},
|
||||
@@ -1706,5 +1730,32 @@
|
||||
"error": "Errore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"training": {
|
||||
"title": "Formazione",
|
||||
"dashboard": "Dashboard",
|
||||
"courses": "Corsi",
|
||||
"registry": "Anagrafica Corsi",
|
||||
"matrix": "Matrice Formazione",
|
||||
"expiring": "In Scadenza",
|
||||
"expired": "Scaduti",
|
||||
"valid": "Valido",
|
||||
"validityDays": "Giorni Validità",
|
||||
"newTraining": "Nuova Formazione",
|
||||
"recordDate": "Data Corso",
|
||||
"expirationDate": "Data Scadenza",
|
||||
"certificate": "Attestato",
|
||||
"upload": "Carica",
|
||||
"download": "Scarica",
|
||||
"status": "Stato",
|
||||
"participant": "Partecipante",
|
||||
"course": "Corso",
|
||||
"deleteConfirm": "Eliminare questa formazione?",
|
||||
"daysRemaining": "Giorni rimanenti",
|
||||
"expiringIn": "Scade tra {{days}} giorni",
|
||||
"sendNotification": "Invia Notifica",
|
||||
"notificationSent": "Notifica inviata con successo",
|
||||
"editCourse": "Modifica Corso",
|
||||
"editTraining": "Modifica Formazione"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ 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 TrainingRoutes from "./apps/training/routes";
|
||||
import { AppGuard } from "./components/AppGuard";
|
||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||
@@ -150,6 +151,15 @@ function App() {
|
||||
</AppGuard>
|
||||
}
|
||||
/>
|
||||
{/* Training Module */}
|
||||
<Route
|
||||
path="training/*"
|
||||
element={
|
||||
<AppGuard appCode="training">
|
||||
<TrainingRoutes />
|
||||
</AppGuard>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</TabProvider>
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
|
||||
Tabs,
|
||||
Tab,
|
||||
} from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
@@ -21,10 +22,167 @@ import {
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { clientiService } from "../../../services/lookupService";
|
||||
import { Cliente } from "../../../types";
|
||||
import { Cliente, ClienteContatto } from "../../../types";
|
||||
import { CustomFieldsRenderer } from "../../../components/customFields/CustomFieldsRenderer";
|
||||
import { CustomFieldValues } from "../../../types/customFields";
|
||||
|
||||
function ContactsManager({ clienteId }: { clienteId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<ClienteContatto>>({});
|
||||
|
||||
const { data: contatti = [], isLoading } = useQuery({
|
||||
queryKey: ["clienti", clienteId, "contatti"],
|
||||
queryFn: () => clientiService.getContatti(clienteId),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<ClienteContatto>) =>
|
||||
clientiService.createContatto(clienteId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clienti", clienteId, "contatti"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<ClienteContatto> }) =>
|
||||
clientiService.updateContatto(clienteId, id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clienti", clienteId, "contatti"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => clientiService.deleteContatto(clienteId, id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clienti", clienteId, "contatti"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingId(null);
|
||||
setFormData({});
|
||||
};
|
||||
|
||||
const handleEdit = (contatto: ClienteContatto) => {
|
||||
setFormData(contatto);
|
||||
setEditingId(contatto.id);
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data: formData });
|
||||
} else {
|
||||
createMutation.mutate({ ...formData, clienteId });
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "nome", headerName: t("common.name"), flex: 1 },
|
||||
{ field: "cognome", headerName: t("common.surname"), flex: 1 },
|
||||
{ field: "ruolo", headerName: t("clients.role"), flex: 1 },
|
||||
{ field: "email", headerName: t("clients.email"), flex: 1 },
|
||||
{ field: "telefono", headerName: t("clients.phone"), flex: 1 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<IconButton size="small" onClick={() => handleEdit(params.row)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
if (confirm(t("common.deleteConfirm"))) {
|
||||
deleteMutation.mutate(params.row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button startIcon={<AddIcon />} variant="contained" onClick={() => setOpenDialog(true)}>
|
||||
{t("clients.newContact")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper sx={{ height: 400, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={contatti}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
pageSizeOptions={[10, 25]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingId ? t("clients.editContact") : t("clients.newContact")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} mt={1}>
|
||||
<TextField
|
||||
label={t("common.name")}
|
||||
value={formData.nome || ""}
|
||||
onChange={(e) => setFormData({ ...formData, nome: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label={t("common.surname")}
|
||||
value={formData.cognome || ""}
|
||||
onChange={(e) => setFormData({ ...formData, cognome: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label={t("clients.role")}
|
||||
value={formData.ruolo || ""}
|
||||
onChange={(e) => setFormData({ ...formData, ruolo: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("clients.email")}
|
||||
value={formData.email || ""}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("clients.phone")}
|
||||
value={formData.telefono || ""}
|
||||
onChange={(e) => setFormData({ ...formData, telefono: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientiPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
@@ -32,6 +190,7 @@ export default function ClientiPage() {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Cliente>>({ attivo: true });
|
||||
const [customFields, setCustomFields] = useState<CustomFieldValues>({});
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const { data: clienti = [], isLoading } = useQuery({
|
||||
queryKey: ["clienti"],
|
||||
@@ -65,6 +224,7 @@ export default function ClientiPage() {
|
||||
setEditingId(null);
|
||||
setFormData({ attivo: true });
|
||||
setCustomFields({});
|
||||
setTabValue(0);
|
||||
};
|
||||
|
||||
const handleEdit = (cliente: Cliente) => {
|
||||
@@ -85,11 +245,9 @@ export default function ClientiPage() {
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
// In modifica, non inviamo il codice (non modificabile)
|
||||
const { codice: _codice, ...updateData } = dataWithCustomFields;
|
||||
updateMutation.mutate({ id: editingId, data: updateData });
|
||||
} else {
|
||||
// In creazione, non inviamo il codice (generato automaticamente)
|
||||
const { codice: _codice, ...createData } = dataWithCustomFields;
|
||||
createMutation.mutate(createData);
|
||||
}
|
||||
@@ -178,192 +336,209 @@ export default function ClientiPage() {
|
||||
{editingId ? t("clients.editClient") : t("clients.newClient")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexWrap="wrap" gap={2} mt={1}>
|
||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.code")}
|
||||
fullWidth
|
||||
value={
|
||||
editingId
|
||||
? formData.codice || ""
|
||||
: t("clients.generatedOnSave")
|
||||
}
|
||||
disabled
|
||||
helperText={
|
||||
editingId
|
||||
? t("clients.autoGenerated")
|
||||
: t("clients.willBeAssigned")
|
||||
}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
sx={
|
||||
!editingId
|
||||
? {
|
||||
"& .MuiInputBase-input.Mui-disabled": {
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
},
|
||||
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} sx={{ mb: 2, borderBottom: 1, borderColor: "divider" }}>
|
||||
<Tab label={t("common.details")} />
|
||||
<Tab label={t("clients.contacts")} disabled={!editingId} />
|
||||
</Tabs>
|
||||
|
||||
<Box role="tabpanel" hidden={tabValue !== 0}>
|
||||
{tabValue === 0 && (
|
||||
<Box display="flex" flexWrap="wrap" gap={2} mt={1}>
|
||||
{/* EXISTING FIELDS */}
|
||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.code")}
|
||||
fullWidth
|
||||
value={
|
||||
editingId
|
||||
? formData.codice || ""
|
||||
: t("clients.generatedOnSave")
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.altCode")}
|
||||
fullWidth
|
||||
value={formData.codiceAlternativo || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codiceAlternativo: e.target.value,
|
||||
})
|
||||
}
|
||||
helperText={t("common.optional")}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.businessName")}
|
||||
fullWidth
|
||||
required
|
||||
value={formData.ragioneSociale || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, ragioneSociale: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.address")}
|
||||
fullWidth
|
||||
value={formData.indirizzo || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, indirizzo: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.zip")}
|
||||
fullWidth
|
||||
value={formData.cap || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cap: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.city")}
|
||||
fullWidth
|
||||
value={formData.citta || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, citta: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.province")}
|
||||
fullWidth
|
||||
value={formData.provincia || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, provincia: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.phone")}
|
||||
fullWidth
|
||||
value={formData.telefono || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, telefono: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.email")}
|
||||
fullWidth
|
||||
type="email"
|
||||
value={formData.email || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.pec")}
|
||||
fullWidth
|
||||
value={formData.pec || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, pec: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.fiscalCode")}
|
||||
fullWidth
|
||||
value={formData.codiceFiscale || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, codiceFiscale: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.vat")}
|
||||
fullWidth
|
||||
value={formData.partitaIva || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, partitaIva: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.recipientCode")}
|
||||
fullWidth
|
||||
value={formData.codiceDestinatario || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codiceDestinatario: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis="100%">
|
||||
<TextField
|
||||
label={t("common.notes")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={formData.note || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, note: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis="100%">
|
||||
<CustomFieldsRenderer
|
||||
entityName="Cliente"
|
||||
values={customFields}
|
||||
onChange={(field, value) => setCustomFields(prev => ({ ...prev, [field]: value }))}
|
||||
/>
|
||||
</Box>
|
||||
disabled
|
||||
helperText={
|
||||
editingId
|
||||
? t("clients.autoGenerated")
|
||||
: t("clients.willBeAssigned")
|
||||
}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
sx={
|
||||
!editingId
|
||||
? {
|
||||
"& .MuiInputBase-input.Mui-disabled": {
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.altCode")}
|
||||
fullWidth
|
||||
value={formData.codiceAlternativo || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codiceAlternativo: e.target.value,
|
||||
})
|
||||
}
|
||||
helperText={t("common.optional")}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.businessName")}
|
||||
fullWidth
|
||||
required
|
||||
value={formData.ragioneSociale || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, ragioneSociale: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.address")}
|
||||
fullWidth
|
||||
value={formData.indirizzo || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, indirizzo: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.zip")}
|
||||
fullWidth
|
||||
value={formData.cap || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cap: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.city")}
|
||||
fullWidth
|
||||
value={formData.citta || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, citta: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.province")}
|
||||
fullWidth
|
||||
value={formData.provincia || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, provincia: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.phone")}
|
||||
fullWidth
|
||||
value={formData.telefono || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, telefono: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.email")}
|
||||
fullWidth
|
||||
type="email"
|
||||
value={formData.email || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.pec")}
|
||||
fullWidth
|
||||
value={formData.pec || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, pec: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.fiscalCode")}
|
||||
fullWidth
|
||||
value={formData.codiceFiscale || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, codiceFiscale: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.vat")}
|
||||
fullWidth
|
||||
value={formData.partitaIva || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, partitaIva: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.recipientCode")}
|
||||
fullWidth
|
||||
value={formData.codiceDestinatario || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codiceDestinatario: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis="100%">
|
||||
<TextField
|
||||
label={t("common.notes")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={formData.note || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, note: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis="100%">
|
||||
<CustomFieldsRenderer
|
||||
entityName="Cliente"
|
||||
values={customFields}
|
||||
onChange={(field, value) => setCustomFields(prev => ({ ...prev, [field]: value }))}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box role="tabpanel" hidden={tabValue !== 1}>
|
||||
{tabValue === 1 && editingId && <ContactsManager clienteId={editingId} />}
|
||||
</Box>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{editingId ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
<Button onClick={handleCloseDialog}>{t("common.close")}</Button>
|
||||
{tabValue === 0 && (
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{editingId ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
||||
13
src/frontend/src/apps/training/components/TrainingLayout.tsx
Normal file
13
src/frontend/src/apps/training/components/TrainingLayout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default function TrainingLayout() {
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", p: 2 }}>
|
||||
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
112
src/frontend/src/apps/training/pages/DashboardPage.tsx
Normal file
112
src/frontend/src/apps/training/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
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";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: expiringRecords = [], isLoading } = useQuery({
|
||||
queryKey: ["training", "expiring"],
|
||||
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;
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "dataScadenza", headerName: t("training.expirationDate"), width: 120, valueFormatter: (params: any) => new Date(params.value).toLocaleDateString() },
|
||||
{ field: "course", headerName: t("training.course"), width: 200, valueGetter: (params: any) => params.row.articolo?.descrizione },
|
||||
{ field: "participant", headerName: t("training.participant"), width: 200, valueGetter: (params: any) => `${params.row.clienteContatto?.cognome} ${params.row.clienteContatto?.nome}` },
|
||||
{
|
||||
field: "stato",
|
||||
headerName: t("training.status"),
|
||||
width: 120,
|
||||
renderCell: (params: any) => (
|
||||
params.row.stato === 2
|
||||
? <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>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Typography variant="h4" mb={3}>{t("training.dashboard")}</Typography>
|
||||
|
||||
<Grid container spacing={3} mb={4}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{t("training.expired")}
|
||||
</Typography>
|
||||
<Typography variant="h3" color="error">
|
||||
{expiredCount}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{t("training.expiring")}
|
||||
</Typography>
|
||||
<Typography variant="h3" color="warning.main">
|
||||
{expiringCount}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h5" mb={2}>{t("training.expiring")}</Typography>
|
||||
<Paper sx={{ height: 400, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={expiringRecords}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
pageSizeOptions={[5, 10]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 5 } } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
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;
|
||||
319
src/frontend/src/apps/training/pages/MatrixPage.tsx
Normal file
319
src/frontend/src/apps/training/pages/MatrixPage.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
UploadFile as UploadIcon,
|
||||
Description as DescriptionIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { trainingService } from "../services/trainingService";
|
||||
import { TrainingRecord, ClienteContatto, Articolo, TipoArticolo } from "../../../types";
|
||||
import { lookupService, articoliService, clientiService } from "../../../services/lookupService";
|
||||
|
||||
export default function MatrixPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<TrainingRecord>>({});
|
||||
const [selectedClient, setSelectedClient] = useState<number | null>(null);
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: records = [], isLoading } = useQuery({
|
||||
queryKey: ["training", "records"],
|
||||
queryFn: () => trainingService.getAll(),
|
||||
});
|
||||
|
||||
const { data: customers = [] } = useQuery({
|
||||
queryKey: ["lookup", "customers"],
|
||||
queryFn: () => lookupService.getClienti(),
|
||||
});
|
||||
|
||||
/* Removed unused trainingCategoryId logic */
|
||||
const { data: courses = [] } = useQuery({
|
||||
queryKey: ["articles", "training"],
|
||||
queryFn: () => articoliService.getAll({ tipo: TipoArticolo.Corso }),
|
||||
});
|
||||
|
||||
const { data: contacts = [] } = useQuery({
|
||||
queryKey: ["contacts", selectedClient],
|
||||
queryFn: () => selectedClient ? clientiService.getContatti(selectedClient) : [],
|
||||
enabled: !!selectedClient,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: Partial<TrainingRecord>) => {
|
||||
const result = await trainingService.create(data);
|
||||
if (fileToUpload) {
|
||||
await trainingService.uploadCertificate(result.id, fileToUpload);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["training", "records"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: number; data: Partial<TrainingRecord> }) => {
|
||||
await trainingService.update(id, data);
|
||||
if (fileToUpload) {
|
||||
await trainingService.uploadCertificate(id, fileToUpload);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["training", "records"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => trainingService.delete(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["training", "records"] }),
|
||||
});
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingId(null);
|
||||
setFormData({});
|
||||
setSelectedClient(null);
|
||||
setFileToUpload(null);
|
||||
};
|
||||
|
||||
const handleEdit = (record: TrainingRecord) => {
|
||||
setFormData(record);
|
||||
setEditingId(record.id);
|
||||
|
||||
// Reverse lookup client from contact if possible?
|
||||
// Not directly available in record unless included.
|
||||
// Record has ClientContatto -> which has ClienteId (if included by backend).
|
||||
// Assuming backend includes ClienteContatto.
|
||||
if (record.clienteContatto) {
|
||||
setSelectedClient(record.clienteContatto.clienteId);
|
||||
}
|
||||
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (status?: number) => {
|
||||
if (status === 2) return <Chip label={t("training.expired")} color="error" size="small" />;
|
||||
if (status === 1) return <Chip label={t("training.expiring")} color="warning" size="small" />;
|
||||
return <Chip label={t("training.valid")} color="success" size="small" />;
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "dataEsecuzione",
|
||||
headerName: t("training.recordDate"),
|
||||
width: 110,
|
||||
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ""
|
||||
},
|
||||
{
|
||||
field: "dataScadenza",
|
||||
headerName: t("training.expirationDate"),
|
||||
width: 110,
|
||||
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ""
|
||||
},
|
||||
{
|
||||
field: "articolo",
|
||||
headerName: t("training.course"),
|
||||
width: 200,
|
||||
valueGetter: (params: any) => params.row.articolo?.descrizione || ""
|
||||
},
|
||||
{
|
||||
field: "clienteContatto",
|
||||
headerName: t("training.participant"),
|
||||
width: 200,
|
||||
valueGetter: (params: any) => {
|
||||
const c = params.row.clienteContatto;
|
||||
return c ? `${c.cognome} ${c.nome}` : "";
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "stato",
|
||||
headerName: t("training.status"),
|
||||
width: 120,
|
||||
renderCell: (params: any) => getStatusChip(params.row.stato)
|
||||
},
|
||||
{
|
||||
field: "attestatoUrl",
|
||||
headerName: t("training.certificate"),
|
||||
width: 100,
|
||||
renderCell: (params: any) => params.value ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
href={`/api/training/${params.row.id}/attestato`}
|
||||
target="_blank"
|
||||
>
|
||||
<DescriptionIcon />
|
||||
</IconButton>
|
||||
) : null
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
renderCell: (params: any) => (
|
||||
<Box>
|
||||
<IconButton size="small" onClick={() => handleEdit(params.row)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
if (confirm(t("common.deleteConfirm"))) {
|
||||
deleteMutation.mutate(params.row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">{t("training.matrix")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setOpenDialog(true)}
|
||||
>
|
||||
{t("training.newTraining")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={records}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
pageSizeOptions={[10, 25]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingId ? t("training.editTraining") : t("training.newTraining")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} mt={1}>
|
||||
<TextField
|
||||
select
|
||||
label={t("clients.businessName")} // Using client label?
|
||||
value={selectedClient || ""}
|
||||
onChange={(e) => setSelectedClient(Number(e.target.value))}
|
||||
fullWidth
|
||||
disabled={!!editingId} // Locked on edit if complex to change
|
||||
>
|
||||
{customers.map((c: any) => (
|
||||
<MenuItem key={c.id} value={c.id}>{c.ragioneSociale}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t("training.participant")}
|
||||
value={formData.clienteContattoId || ""}
|
||||
onChange={(e) => setFormData({ ...formData, clienteContattoId: Number(e.target.value) })}
|
||||
fullWidth
|
||||
required
|
||||
disabled={!selectedClient}
|
||||
>
|
||||
{contacts.map((c: ClienteContatto) => (
|
||||
<MenuItem key={c.id} value={c.id}>{c.cognome} {c.nome}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t("training.course")}
|
||||
value={formData.articoloId || ""}
|
||||
onChange={(e) => setFormData({ ...formData, articoloId: Number(e.target.value) })}
|
||||
fullWidth
|
||||
required
|
||||
>
|
||||
{courses.map((c: Articolo) => (
|
||||
<MenuItem key={c.id} value={c.id}>{c.descrizione}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
type="date"
|
||||
label={t("training.recordDate")}
|
||||
value={formData.dataEsecuzione ? formData.dataEsecuzione.split('T')[0] : ""}
|
||||
onChange={(e) => setFormData({ ...formData, dataEsecuzione: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
>
|
||||
{t("training.upload")}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
onChange={(e) => setFileToUpload(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</Button>
|
||||
{fileToUpload && <Typography variant="caption">{fileToUpload.name}</Typography>}
|
||||
|
||||
<TextField
|
||||
label={t("common.notes")}
|
||||
multiline
|
||||
rows={3}
|
||||
value={formData.note || ""}
|
||||
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
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;
|
||||
262
src/frontend/src/apps/training/pages/RegistryPage.tsx
Normal file
262
src/frontend/src/apps/training/pages/RegistryPage.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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();
|
||||
const queryClient = useQueryClient();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Articolo>>({
|
||||
attivo: true,
|
||||
tipo: TipoArticolo.Corso
|
||||
});
|
||||
|
||||
// 1. Fetch Request ALL Categories
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["warehouse-categories"],
|
||||
queryFn: () => categoryService.getAll(false),
|
||||
});
|
||||
|
||||
const trainingCategoryId = useMemo(() => {
|
||||
return categories.find((c: any) => c.code === "TRAIN")?.id;
|
||||
}, [categories]);
|
||||
|
||||
// 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"],
|
||||
queryFn: () => {
|
||||
// We explicitly want ALL courses, regardless of subcategory
|
||||
const params: any = { tipo: TipoArticolo.Corso };
|
||||
return articoliService.getAll(params);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<Articolo>) => articoliService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["articles", "training"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<Articolo> }) =>
|
||||
articoliService.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["articles", "training"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => articoliService.delete(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["articles", "training"] }),
|
||||
});
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingId(null);
|
||||
setFormData({ attivo: true, tipo: TipoArticolo.Corso });
|
||||
};
|
||||
|
||||
const handleEdit = (article: Articolo) => {
|
||||
setFormData(article);
|
||||
setEditingId(article.id);
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!trainingCategoryId) {
|
||||
// Warning but proceed with just Type? User said "linked to warehouse articles but with specific classification".
|
||||
// Classification (Type) is key. Category is secondary but described in plan.
|
||||
// If "TRAIN" category exists we use it, otherwise we rely on Tipo.
|
||||
// But the previous code had an alert.
|
||||
// I'll keep the alert if strict, or maybe auto-create category?
|
||||
// Let's keep strictness on Category if plan required it.
|
||||
}
|
||||
|
||||
if (!trainingCategoryId) {
|
||||
alert("Errore: Categoria 'Formazione' non trovata. Contattare l'amministratore.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
categoriaId: formData.categoriaId || trainingCategoryId, // Use selected or default to TRAIN
|
||||
tipoMaterialeId: 1, // Default Material Type ID
|
||||
unitaMisura: "H", // Hours
|
||||
tipo: TipoArticolo.Corso, // Force Type
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data: dataToSave });
|
||||
} else {
|
||||
createMutation.mutate(dataToSave);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "codice", headerName: t("training.course"), width: 120 },
|
||||
{ field: "descrizione", headerName: t("common.description"), flex: 1 },
|
||||
{ field: "giorniValidita", headerName: t("training.validityDays"), width: 150 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<IconButton size="small" onClick={() => handleEdit(params.row)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
if (confirm(t("common.deleteConfirm"))) {
|
||||
deleteMutation.mutate(params.row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!trainingCategoryId && !isLoading && categories.length > 0) {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Typography color="error">
|
||||
Categoria "Formazione" (TRAIN) non trovata. Eseguire il seed del database.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">{t("training.registry")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setOpenDialog(true)}
|
||||
>
|
||||
{t("training.newTraining")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={articles}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
pageSizeOptions={[10, 25]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingId ? t("training.editCourse") : t("training.newTraining")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} mt={1}>
|
||||
<TextField
|
||||
label={t("common.code")}
|
||||
fullWidth
|
||||
value={editingId ? formData.codice : t("clients.generatedOnSave")}
|
||||
disabled
|
||||
InputProps={{ readOnly: true }}
|
||||
/>
|
||||
<TextField
|
||||
label={t("common.description")}
|
||||
value={formData.descrizione || ""}
|
||||
onChange={(e) => setFormData({ ...formData, descrizione: e.target.value })}
|
||||
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"
|
||||
value={formData.giorniValidita || ""}
|
||||
onChange={(e) => setFormData({ ...formData, giorniValidita: parseInt(e.target.value) || 0 })}
|
||||
fullWidth
|
||||
helperText="Giorni dopo i quali il corso scade"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
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;
|
||||
27
src/frontend/src/apps/training/routes.tsx
Normal file
27
src/frontend/src/apps/training/routes.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import TrainingLayout from "./components/TrainingLayout";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
import RegistryPage from "./pages/RegistryPage";
|
||||
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() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<TrainingLayout />}>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="registry" element={<RegistryPage />} />
|
||||
<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>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
33
src/frontend/src/apps/training/services/trainingService.ts
Normal file
33
src/frontend/src/apps/training/services/trainingService.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import api from "../../../services/api";
|
||||
import { TrainingRecord } from "../../../types";
|
||||
|
||||
export const trainingService = {
|
||||
getAll: async (params?: { start?: string; end?: string; customerId?: number; courseId?: number }) => {
|
||||
const { data } = await api.get<TrainingRecord[]>("/training", { params });
|
||||
return data;
|
||||
},
|
||||
create: async (record: Partial<TrainingRecord>) => {
|
||||
const { data } = await api.post<TrainingRecord>("/training", record);
|
||||
return data;
|
||||
},
|
||||
update: async (id: number, record: Partial<TrainingRecord>) => {
|
||||
await api.put(`/training/${id}`, record);
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
await api.delete(`/training/${id}`);
|
||||
},
|
||||
uploadCertificate: async (id: number, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await api.post(`/training/${id}/attestato`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
});
|
||||
},
|
||||
getExpiring: async () => {
|
||||
const { data } = await api.get<TrainingRecord[]>("/training/expiring");
|
||||
return data;
|
||||
},
|
||||
sendNotification: async (id: number) => {
|
||||
await api.post(`/training/${id}/notify`);
|
||||
}
|
||||
};
|
||||
@@ -105,6 +105,7 @@ export default function ArticleFormPage() {
|
||||
isSerialManaged: false,
|
||||
hasExpiry: false,
|
||||
expiryWarningDays: 30,
|
||||
giorniValidita: undefined as number | undefined,
|
||||
isActive: true,
|
||||
notes: "",
|
||||
});
|
||||
@@ -159,6 +160,7 @@ export default function ArticleFormPage() {
|
||||
isSerialManaged: article.isSerialManaged,
|
||||
hasExpiry: article.hasExpiry,
|
||||
expiryWarningDays: article.expiryWarningDays || 30,
|
||||
giorniValidita: article.giorniValidita,
|
||||
isActive: article.isActive,
|
||||
notes: article.notes || "",
|
||||
});
|
||||
@@ -234,6 +236,7 @@ export default function ArticleFormPage() {
|
||||
isSerialManaged: formData.isSerialManaged,
|
||||
hasExpiry: formData.hasExpiry,
|
||||
expiryWarningDays: formData.expiryWarningDays,
|
||||
giorniValidita: formData.giorniValidita,
|
||||
notes: formData.notes || undefined,
|
||||
};
|
||||
const result = await createMutation.mutateAsync(createData);
|
||||
@@ -258,6 +261,7 @@ export default function ArticleFormPage() {
|
||||
isSerialManaged: formData.isSerialManaged,
|
||||
hasExpiry: formData.hasExpiry,
|
||||
expiryWarningDays: formData.expiryWarningDays,
|
||||
giorniValidita: formData.giorniValidita,
|
||||
isActive: formData.isActive,
|
||||
notes: formData.notes || undefined,
|
||||
};
|
||||
@@ -625,6 +629,20 @@ export default function ArticleFormPage() {
|
||||
label={t("warehouse.articleForm.fields.expiryManaged")}
|
||||
/>
|
||||
</Grid>
|
||||
{formData.hasExpiry && (
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t("training.validityDays")}
|
||||
type="number"
|
||||
value={formData.giorniValidita || ""}
|
||||
onChange={(e) =>
|
||||
handleChange("giorniValidita", parseInt(e.target.value) || undefined)
|
||||
}
|
||||
helperText="Giorni di validità standard (per corsi)"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid size={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
UpdateArticleDto,
|
||||
UpdateBatchDto,
|
||||
UpdateCategoryDto,
|
||||
UpdateWarehouseDto,
|
||||
ValuationMethod,
|
||||
WarehouseLocationDto,
|
||||
} from "../types";
|
||||
|
||||
@@ -253,6 +253,7 @@ export interface ArticleDto {
|
||||
isSerialManaged: boolean;
|
||||
hasExpiry: boolean;
|
||||
expiryWarningDays?: number;
|
||||
giorniValidita?: number;
|
||||
minimumStock?: number;
|
||||
maximumStock?: number;
|
||||
reorderPoint?: number;
|
||||
@@ -288,6 +289,7 @@ export interface CreateArticleDto {
|
||||
isSerialManaged: boolean;
|
||||
hasExpiry: boolean;
|
||||
expiryWarningDays?: number;
|
||||
giorniValidita?: number;
|
||||
minimumStock?: number;
|
||||
maximumStock?: number;
|
||||
reorderPoint?: number;
|
||||
@@ -317,6 +319,7 @@ export interface UpdateArticleDto {
|
||||
isSerialManaged: boolean;
|
||||
hasExpiry: boolean;
|
||||
expiryWarningDays?: number;
|
||||
giorniValidita?: number;
|
||||
minimumStock?: number;
|
||||
maximumStock?: number;
|
||||
reorderPoint?: number;
|
||||
|
||||
@@ -109,6 +109,14 @@ export default function SearchBar() {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeAppCodes.includes('training')) {
|
||||
opts.push(
|
||||
{ label: t('apps.training.dashboard'), path: '/training/dashboard', category: t('apps.training.title'), translationKey: 'apps.training.dashboard' },
|
||||
{ label: t('apps.training.registry'), path: '/training/registry', category: t('apps.training.title'), translationKey: 'apps.training.registry' },
|
||||
{ label: t('apps.training.matrix'), path: '/training/matrix', category: t('apps.training.title'), translationKey: 'apps.training.matrix' }
|
||||
);
|
||||
}
|
||||
|
||||
opts.push(
|
||||
{ label: t('menu.apps'), path: '/apps', category: t('menu.administration'), translationKey: 'menu.apps' },
|
||||
{ label: t('menu.autoCodes'), path: '/admin/auto-codes', category: t('menu.administration'), translationKey: 'menu.autoCodes' },
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Email as EmailIcon,
|
||||
School as SchoolIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -78,6 +79,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
|
||||
production: false,
|
||||
events: false,
|
||||
hr: false,
|
||||
training: false,
|
||||
admin: false,
|
||||
});
|
||||
|
||||
@@ -186,6 +188,22 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
|
||||
{ id: 'hr-rimborsi', label: t('apps.hr.rimborsi'), icon: <ReceiptIcon />, path: '/hr/rimborsi', translationKey: 'apps.hr.rimborsi' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'training',
|
||||
label: t('apps.training.title'),
|
||||
icon: <SchoolIcon />,
|
||||
appCode: 'training',
|
||||
translationKey: 'apps.training.title',
|
||||
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-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-data', label: t('apps.training.dataExchange'), icon: <SwapIcon />, path: '/training/data-exchange', translationKey: 'apps.training.dataExchange' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
label: t('menu.administration'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import api from './api';
|
||||
import { Cliente, Location, Risorsa, Articolo, LookupItem } from '../types';
|
||||
import { Cliente, Location, Risorsa, Articolo, LookupItem, ClienteContatto } from '../types';
|
||||
|
||||
export const lookupService = {
|
||||
getTipiEvento: async () => {
|
||||
@@ -72,6 +72,20 @@ export const clientiService = {
|
||||
delete: async (id: number) => {
|
||||
await api.delete(`/clienti/${id}`);
|
||||
},
|
||||
getContatti: async (id: number) => {
|
||||
const { data } = await api.get<ClienteContatto[]>(`/clienti/${id}/contatti`);
|
||||
return data;
|
||||
},
|
||||
createContatto: async (id: number, contatto: Partial<ClienteContatto>) => {
|
||||
const { data } = await api.post<ClienteContatto>(`/clienti/${id}/contatti`, contatto);
|
||||
return data;
|
||||
},
|
||||
updateContatto: async (id: number, contattoId: number, contatto: Partial<ClienteContatto>) => {
|
||||
await api.put(`/clienti/${id}/contatti/${contattoId}`, contatto);
|
||||
},
|
||||
deleteContatto: async (id: number, contattoId: number) => {
|
||||
await api.delete(`/clienti/${id}/contatti/${contattoId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const locationService = {
|
||||
@@ -117,7 +131,7 @@ export const risorseService = {
|
||||
};
|
||||
|
||||
export const articoliService = {
|
||||
getAll: async (params?: { search?: string; tipoMaterialeId?: number; categoriaId?: number; attivo?: boolean }) => {
|
||||
getAll: async (params?: { search?: string; tipoMaterialeId?: number; categoriaId?: number; attivo?: boolean; tipo?: number }) => {
|
||||
const { data } = await api.get<Articolo[]>('/articoli', { params });
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -4,6 +4,12 @@ export enum StatoEvento {
|
||||
Confermato = 20,
|
||||
}
|
||||
|
||||
export enum TipoArticolo {
|
||||
Standard = 0,
|
||||
Corso = 1,
|
||||
Servizio = 2,
|
||||
}
|
||||
|
||||
export interface BaseEntity {
|
||||
id: number;
|
||||
createdAt?: string;
|
||||
@@ -29,6 +35,16 @@ export interface Cliente extends BaseEntity {
|
||||
codiceDestinatario?: string;
|
||||
note?: string;
|
||||
attivo: boolean;
|
||||
contatti?: ClienteContatto[];
|
||||
}
|
||||
|
||||
export interface ClienteContatto extends BaseEntity {
|
||||
clienteId: number;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
email?: string;
|
||||
telefono?: string;
|
||||
ruolo?: string;
|
||||
}
|
||||
|
||||
export interface Location extends BaseEntity {
|
||||
@@ -105,6 +121,8 @@ export interface Articolo extends BaseEntity {
|
||||
unitaMisura?: string;
|
||||
note?: string;
|
||||
attivo: boolean;
|
||||
giorniValidita?: number;
|
||||
tipo?: TipoArticolo;
|
||||
}
|
||||
|
||||
export interface Evento extends BaseEntity {
|
||||
@@ -294,3 +312,15 @@ export interface LookupItem {
|
||||
citta?: string;
|
||||
tipo?: string;
|
||||
}
|
||||
|
||||
export interface TrainingRecord extends BaseEntity {
|
||||
clienteContattoId: number;
|
||||
clienteContatto?: ClienteContatto;
|
||||
articoloId: number;
|
||||
articolo?: Articolo;
|
||||
dataEsecuzione: string;
|
||||
dataScadenza?: string;
|
||||
attestatoUrl?: string;
|
||||
note?: string;
|
||||
stato?: number; // 0=Valid, 1=Expiring, 2=Expired
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user