Compare commits
12 Commits
6d1aef3a42
...
v1.0.0-obi
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f954f494 | |||
| 99ce5e1e6a | |||
| 4810d49410 | |||
| 49abef6f96 | |||
| 64d93a936c | |||
| 0314b40f92 | |||
| c4d58f8354 | |||
| 08256f0019 | |||
| 54cf1ff276 | |||
| ad5a880219 | |||
| 9174e75be0 | |||
| dedd4f4e69 |
27
.agent/rules/customizations-folders.md
Normal file
27
.agent/rules/customizations-folders.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
trigger: model_decision
|
||||
description: Quando è richiesta una feature specifica per un cliente, non inerente allo standard
|
||||
---
|
||||
|
||||
usa ./docs/development/devlog/customizations per tenere traccia di tutti i piani di lavoro custom e il loro attuale stato singolarmente, crea qui dentro i log delle lavorazioni ed il lavoro fatto, da fare e suggerito per ogni piano di sviluppo, usa il formato "yyyy-mm-dd-hh24miss_descrizione_brevissima".
|
||||
|
||||
usa ./docs/development per tenere un file ZENTRAL_CUSTOM.md riassuntivo con link ai file specifici dentro ./docs/development/devlog/customizations e una breve sintesi specificando che tipo di sviluppo si è concluso o si sta lavorando.
|
||||
|
||||
## Struttura Modulare del Progetto Custom
|
||||
|
||||
Per ogni modulo custom specificatamente sviluppato per una richiesta cliente è necessario prima trovare il miglior modo per integrare questo modulo custom il più possibile con i moduli esistenti, evitando di duplicare il codice e permettendo di scrivere meno codice possibile.
|
||||
|
||||
### Backend (.NET)
|
||||
- **API Controllers**: `src/backend/Zentral.API/Modules/Custom/[NomeModulo]/Controllers/`
|
||||
- I controller devono avere il namespace `Zentral.API.Modules.[NomeModulo].Controllers`.
|
||||
- Le rotte devono seguire il pattern `api/custom/[nome-modulo]/[controller]`.
|
||||
- **Entities**: `src/backend/Zentral.Domain/Entities/Custom/[NomeModulo]/`
|
||||
- Le entità devono avere il namespace `Zentral.Domain.Entities.Custom.[NomeModulo]`.
|
||||
|
||||
### Frontend (React)
|
||||
- **Moduli**: `src/frontend/src/modules/custom/[nome-modulo]/`
|
||||
- **Pagine**: `src/frontend/src/modules/custom/[nome-modulo]/pages/`
|
||||
- **Componenti**: `src/frontend/src/modules/custom/[nome-modulo]/components/`
|
||||
- **Rotte**: `src/frontend/src/modules/custom/[nome-modulo]/routes.tsx`
|
||||
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||
@@ -27,4 +27,4 @@ Il progetto segue una rigorosa struttura modulare sia per il backend che per il
|
||||
- **Componenti**: `src/frontend/src/modules/[nome-modulo]/components/`
|
||||
- **Rotte**: `src/frontend/src/modules/[nome-modulo]/routes.tsx`
|
||||
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||
@@ -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.
|
||||
|
||||
@@ -12,6 +12,8 @@ Il software si chiama Zentral e, tramite diverse applicazioni, si occupa di gest
|
||||
- magazzino (Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte)
|
||||
- HR (o personale) (Gestione personale, contratti, pagamenti, assenze, rimborsi e analisi personale)
|
||||
- report e stampe (Gestione report, creazione e analisi report)
|
||||
- comunicazioni (Gestione invio mail, chat interna, condivisione risorse del gestionale ad interni ed esterni)
|
||||
- corsi e formazione (Gestione corsi di formazione, erogazione corsi, tracciabilità scadenze)
|
||||
|
||||
mostra statistiche grafiche per ogni applicazione nella dashboard dell'applicazione.
|
||||
|
||||
|
||||
@@ -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**
|
||||
@@ -20,7 +23,7 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
||||
- Implementazione modulo Gestione Eventi: strutturazione frontend, integrazione funzionalità e attivazione store.
|
||||
- [Event Module Development](./devlog/event-module.md) - Implementazione modulo eventi
|
||||
- [Menu Refactoring](./devlog/menu-refactoring.md) - Riorganizzazione menu e moduli (Dashboard, Clienti, Articoli, Risorse)
|
||||
- [2025-12-03 Implementazione Modulo Personale](./devlog/2025-12-03_implementazione_modulo_personale.md) - **In Corso**
|
||||
- [2025-12-03 Implementazione Modulo Personale](./devlog/2025-12-03_implementazione_modulo_personale.md) - **Completato**
|
||||
- Implementazione entità, API e Frontend per gestione Personale (Dipendenti, Contratti, Assenze, Pagamenti).
|
||||
- [2025-12-04 Zentral Dashboard and Menu Cleanup](./devlog/2025-12-04-023000_zentral_dashboard.md) - **Completato**
|
||||
- Pulizia menu Zentral (rimozione voci ridondanti) e creazione nuova Dashboard principale con riepilogo moduli attivi.
|
||||
@@ -48,4 +51,11 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
||||
- [2025-12-06 01:35:00 - Fix Traduzione Tab Applicazioni](./devlog/2025-12-06-013500_fix_apps_tab_translation.md) - **Completato**
|
||||
- Corretta chiave di traduzione errata per la tab "Gestione Applicazioni" e migliorata la gestione dell'aggiornamento etichette tab.
|
||||
- [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato**
|
||||
- Riorganizzazione UI Auto Codes, allineamento stile a Custom Fields, miglioramento traduzioni e categorizzazione.
|
||||
- [2025-12-12 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**
|
||||
- Sostituita la logica "Gruppi Merceologici" con l'utilizzo esteso delle "Categorie Articoli" gerarchiche.
|
||||
- [2025-12-12 Update Translations](./devlog/2025-12-12-141010_update_translations.md) - **Completato**
|
||||
- Aggiornamento traduzioni per categorie magazzino, comunicazioni e formazione.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Implementazione Modulo Formazione (Generale)
|
||||
|
||||
## Obiettivo
|
||||
Creare un modulo generale per la gestione della formazione (Training), permettendo all'utente di definire corsi di diverso tipo (es. Sicurezza, Tecnici, Qualità, Soft Skills) in base alle esigenze del business. Il sistema gestirà scadenze, attestati e partecipanti in modo agnostico rispetto al tipo di corso.
|
||||
|
||||
## Strategia
|
||||
Mapping delle funzionalità sui moduli esistenti:
|
||||
1. **Anagrafica Corsi** -> Modulo **Magazzino** (`Articolo`)
|
||||
- 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.
|
||||
|
||||
## Piano di Lavoro
|
||||
|
||||
### 1. Documentazione e Analisi
|
||||
- [x] Creazione piano di lavoro (questo file).
|
||||
- [x] Aggiornamento `ZENTRAL.md`.
|
||||
|
||||
### 2. Backend (.NET)
|
||||
#### Domain Layer
|
||||
- [x] **Refactoring Categorie (Warehouse)**:
|
||||
- Implementare gestione **Gruppi Merceologici a 3 livelli** (Standardizzazione Classificazione).
|
||||
- Utilizzare la categoria "Formazione" come root per identificare i corsi.
|
||||
- [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.
|
||||
- [x] **Nuova Entity `ClienteContatto`**:
|
||||
- Proprietà: `Nome`, `Cognome`, `Email`, `Ruolo`, `Telefono`, foreign key a `Cliente`.
|
||||
- Aggiornare `Cliente` con collection `Contatti`.
|
||||
- [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
|
||||
- [x] Creare Migrazione EF per le nuove entità e modifiche.
|
||||
- [x] Aggiornare `ApplicationDbContext`.
|
||||
|
||||
#### API Layer
|
||||
- [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).
|
||||
- Endpoint `approve-notification`: Invio email notifiche scadenze.
|
||||
|
||||
### 3. Frontend (React)
|
||||
#### Modulo Training (Nuova App `training`)
|
||||
- [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
|
||||
- [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
|
||||
- [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:
|
||||
1. Creazione "Tipo Corso" (Sottocategoria).
|
||||
2. Creazione Corso con validità.
|
||||
3. Creazione Contatto.
|
||||
4. Registrazione Formazione.
|
||||
5. Verifica Scadenza e Notifica.
|
||||
|
||||
## Stato Attuale
|
||||
- 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,51 @@
|
||||
# Implementazione Modulo Comunicazioni (Ex Email Standard)
|
||||
|
||||
## Obiettivo
|
||||
Implementare il modulo **Comunicazioni** (`communications`), inizialmente focalizzato sulla gestione centralizzata dell'invio email (SMTP).
|
||||
Questo modulo servirà da fondamento per tutte le comunicazioni in uscita (e in futuro interne) del gestionale.
|
||||
|
||||
## Strategia
|
||||
Il modulo gestirà sia l'infrastruttura tecnica (Service Layer per invio mail) sia l'interfaccia utente per la configurazione e il monitoraggio (Log).
|
||||
Sarà allineato alla visione del modulo "Comunicazioni" (Gestione invio mail, chat interna, ecc.).
|
||||
|
||||
## Piano di Lavoro
|
||||
|
||||
### 1. Documentazione
|
||||
- [x] Aggiornamento piano di lavoro (questo file).
|
||||
- [x] Aggiornamento `ZENTRAL.md`.
|
||||
|
||||
### 2. Backend (.NET)
|
||||
#### Domain Layer (`Zentral.Domain`)
|
||||
- [x] **Interfaccia `IEmailSender`**: Contratto standard per l'invio.
|
||||
- [x] **Entities (Namespace `Communications`)**:
|
||||
- `EmailLog`: Storico invii (`Id`, `Data`, `Mittente`, `Destinatario`, `Oggetto`, `Stato`, `Errore`).
|
||||
- `EmailTemplate` (Opzionale Fase 1): Per standardizzare il layout delle mail.
|
||||
|
||||
#### Infrastructure Layer (`Zentral.Infrastructure`)
|
||||
- [x] **Implementazione `SmtpEmailSender`**:
|
||||
- Logica di invio tramite MailKit.
|
||||
- Integrazione con `Configurazione` per leggere le credenziali SMTP a runtime.
|
||||
- Salvataggio automatico del log in `EmailLog`.
|
||||
|
||||
#### API Layer (`Zentral.API`)
|
||||
- [x] **Controller `CommunicationsController`**:
|
||||
- Endpoint per test invio.
|
||||
- Endpoint per consultazione Logs.
|
||||
- Endpoint per salvataggio Configurazione SMTP.
|
||||
|
||||
### 3. Frontend (React)
|
||||
#### Modulo `communications` (`src/apps/communications`)
|
||||
- [x] **Setup App**: Creazione struttura standard modulo.
|
||||
- [x] **Settings Page**:
|
||||
- Form per configurazione SMTP (Host, Port, User, Pass, SSL).
|
||||
- Pulsante "Test Connessione".
|
||||
- [x] **Logs Page**:
|
||||
- Tabella visualizzazione storico email inviate con stato (Successo/Errore).
|
||||
|
||||
## Integrazione
|
||||
- Il servizio `IEmailSender` sarà iniettato negli altri moduli (es. Safety) per l'invio delle notifiche.
|
||||
|
||||
## Verifica
|
||||
- [ ] Configurazione SMTP (es. Mailtrap).
|
||||
- [ ] Test invio mail da interfaccia.
|
||||
- [ ] Verifica scrittura Log su DB.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Implementazione Configurazione Email in Amministrazione
|
||||
|
||||
## Obiettivo
|
||||
Rendere disponibile la configurazione dell'invio email del modulo Comunicazioni nella sezione Amministrazione dell'interfaccia grafica.
|
||||
|
||||
## Stato Attuale
|
||||
- Il backend ha già gli endpoint per la configurazione SMTP (`api/communications/config`).
|
||||
- Esiste già una pagina `SettingsPage` nel modulo Comunicazioni (`src/frontend/src/apps/communications/pages/SettingsPage.tsx`) che gestisce il form di configurazione.
|
||||
- Il modulo Comunicazioni non è attualmente visibile nel menu principale se non attivo/acquistato, ma la configurazione email è un setting globale che dovrebbe essere accessibile.
|
||||
|
||||
## Piano di Lavoro
|
||||
1. **Aggiornamento Route**: Aggiungere una route `/admin/email-config` in `App.tsx` che punta alla pagina di configurazione esistente (o un wrapper).
|
||||
2. **Aggiornamento Menu**: Aggiungere la voce "Configurazione Email" nel menu "Amministrazione" in `Sidebar.tsx`.
|
||||
3. **Traduzioni**: Aggiungere le chiavi di traduzione per la nuova voce di menu in `it/translation.json` e `en/translation.json`.
|
||||
4. **Test**: Avviare l'applicazione e verificare che la pagina sia accessibile e funzionante.
|
||||
|
||||
## Dettagli Tecnici
|
||||
- Riutilizzare `src/frontend/src/apps/communications/pages/SettingsPage.tsx`.
|
||||
- La route sarà protetta se necessario, ma accessibile come parte dell'amministrazione.
|
||||
|
||||
## Stato Finale
|
||||
- [x] Aggiunta route `/admin/email-config` in `App.tsx`.
|
||||
- [x] Aggiunta voce menu "Configurazione Email" in `Sidebar.tsx`.
|
||||
- [x] Aggiunte traduzioni IT ed EN.
|
||||
- [x] Installato .NET 9.0 SDK via script locale (`~/.dotnet`).
|
||||
- [x] Installato `dotnet-ef` tool.
|
||||
- [x] Creata migrazione `UpdateCommunicationsModule` e aggiornato il database.
|
||||
- [x] Backend avviato su porta 5000.
|
||||
- [x] Frontend avviato su porta 5173.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Integrazione Supporto Resend per Invio Email
|
||||
|
||||
## Obiettivo
|
||||
Abilitare l'invio di email tramite servizi terzi (Resend) oltre al già presente SMTP, con configurazione via interfaccia grafica.
|
||||
|
||||
## Stato Attuale
|
||||
- Backend: `SmtpEmailSender` gestisce solo SMTP.
|
||||
- Frontend: `SettingsPage` gestisce solo campi SMTP.
|
||||
- DTO: `SmtpConfigDto` limitato a SMTP.
|
||||
|
||||
## Piano di Lavoro
|
||||
1. **Backend DTO**: Aggiornare `SmtpConfigDto` con campi `Provider` e `ResendApiKey`.
|
||||
2. **Backend Controller**: Aggiornare `CommunicationsController` per leggere/salvare le nuove configurazioni (`EMAIL_PROVIDER`, `RESEND_API_KEY`).
|
||||
3. **Backend Service**: Modificare `SmtpEmailSender` (o rinominarlo in `UnifiedEmailSender`) per supportare la logica condizionale (SMTP vs Resend). Implementare l'invio tramite HTTP Client per Resend.
|
||||
4. **Frontend Service**: Aggiornare le definizioni di tipo TypeScript.
|
||||
5. **Frontend UI**: Modificare `SettingsPage` per aggiungere un selettore di provider (SMTP/Resend) e mostrare i campi pertinenti dinamicamente.
|
||||
6. **Traduzioni**: Aggiungere le nuove etichette.
|
||||
|
||||
## Dettagli Tecnici
|
||||
- **API Resend**: Richiesta POST a `https://api.resend.com/emails` con Bearer Token.
|
||||
- **Provider Enum**: "smtp", "resend".
|
||||
- **Defaut**: SMTP per retrocompatibilità.
|
||||
|
||||
## Avanzamento
|
||||
- [x] Backend DTO Update (`SmtpConfigDto`)
|
||||
- [x] Backend Controller Update (`CommunicationsController`)
|
||||
- [x] Backend Service Logic (`SmtpEmailSender` now handles Resend via HTTP)
|
||||
- [x] Frontend Types Update
|
||||
- [x] Frontend UI Update (`SettingsPage.tsx` with Provider selector)
|
||||
- [x] Dependencies (Added `Microsoft.Extensions.Http` to Infrastructure)
|
||||
|
||||
## Note Finali
|
||||
- L'integrazione supporta ora la selezione dinamica tra SMTP e Resend.
|
||||
- La configurazione viene salvata su database (`EMAIL_PROVIDER`, `RESEND_API_KEY`).
|
||||
- Il backend utilizza `IHttpClientFactory` per le chiamate API verso Resend.
|
||||
- UI aggiornata per mostrare campi condizionali.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Implementazione Gruppi Merceologici Magazzino
|
||||
|
||||
## Richiesta
|
||||
Implementare la gestione dei gruppi merceologici per la categorizzazione degli articoli nel modulo magazzino, sia backend che frontend.
|
||||
|
||||
## Stato Attuale
|
||||
- Esiste già una gestione di "Categorie Articoli" (`WarehouseArticleCategory`) che è gerarchica.
|
||||
- "Gruppi Merceologici" (`WarehouseProductGroup`) sarà una nuova entità, probabilmente una classificazione parallela non gerarchica (o piatta) spesso usata per fini statistici o contabili, o semplicemente come raggruppamento alternativo.
|
||||
|
||||
## Piano di Lavoro
|
||||
|
||||
### Backend
|
||||
1. **Domain Layer**
|
||||
- Creare entità `WarehouseProductGroup` in `Zentral.Domain.Entities.Warehouse`.
|
||||
- Campi: Code, Name, Description, IsActive.
|
||||
- Aggiornare `WarehouseArticle` aggiungendo FK `ProductGroupId` e navigation property.
|
||||
2. **Infrastructure Layer**
|
||||
- Aggiungere `DbSet<WarehouseProductGroup>` in `ApplicationDbContext`.
|
||||
- Configurare le relazioni entity framework se necessario.
|
||||
- Creare Migrazione `AddWarehouseProductGroups`.
|
||||
3. **Service Layer**
|
||||
- Aggiornare `IWarehouseService` e `WarehouseService` con i metodi CRUD per i gruppi merceologici.
|
||||
4. **API Layer**
|
||||
- Creare `WarehouseProductGroupsController`.
|
||||
- Aggiornare DTOs degli articoli per includere `ProductGroupId`.
|
||||
|
||||
### Frontend
|
||||
1. **Services**
|
||||
- Creare `productGroupService.ts` per chiamare le API.
|
||||
2. **Pages**
|
||||
- Creare `ProductGroupsPage` per elenco e gestione (CRUD).
|
||||
3. **Components**
|
||||
- Aggiornare il form di creazione/modifica articolo per permettere la selezione del gruppo merceologico.
|
||||
4. **Routing & Navigation**
|
||||
- Aggiungere rotta per `ProductGroupsPage`.
|
||||
- Aggiungere voce di menu nella sidebar del magazzino.
|
||||
|
||||
## Note
|
||||
- L'implementazione seguirà lo stile esistente del modulo Warehouse, usando Services e Controllers.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Sostituzione Gruppi Merceologici con Categorie Gerarchiche
|
||||
|
||||
## Stato Corrente
|
||||
IMPLEMENTATO
|
||||
|
||||
## Descrizione
|
||||
Sostituita la gestione separata dei "Gruppi Merceologici" con l'utilizzo potenziato delle Categorie Articoli (`WarehouseArticleCategory`) già esistenti e gerarchiche.
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### Backend
|
||||
- **Revert**: Rimossa entity `WarehouseProductGroup` e relativi controller e service.
|
||||
- **Migration**: Creata e applicata migrazione `RemoveWarehouseProductGroups` per rimuovere la tabella dal database.
|
||||
- **Services**: `WarehouseService` ripulito da logica `ProductGroups`.
|
||||
|
||||
### Frontend
|
||||
- **Revert**: Rimossa pagina `ProductGroupsPage` e riferimenti nel codice.
|
||||
- **New Feature**: Creata pagina `CategoriesPage` (`/warehouse/categories`) per gestire le categorie in modalità albero.
|
||||
- Create
|
||||
- Update
|
||||
- Delete
|
||||
- Struttura gerarchica visualizzata (Tree View).
|
||||
- **Article Form**: Rimossa selezione "Gruppo Merceologico". La selezione della categoria utilizza `CategoryTree` appiattito per la selezione.
|
||||
- **Navigation**: Aggiunto link "Categorie" nella sidebar del Magazzino.
|
||||
|
||||
## Note Tecniche
|
||||
- La gestione delle categorie sfrutta la ricorsività supportata dall'entity `WarehouseArticleCategory`.
|
||||
- L'interfaccia utente permette di gestire la gerarchia creando categorie "root" o sottocategorie.
|
||||
|
||||
## Verifica
|
||||
- **Backend API**:
|
||||
- `GET /api/warehouse/categories` -> Disponibile.
|
||||
- `GET /api/warehouse/categories/tree` -> Disponibile (ritorna JSON corretto).
|
||||
- `GET /api/warehouse/product-groups` -> **404 Not Found** (Correttamente rimosso).
|
||||
@@ -0,0 +1,21 @@
|
||||
# Update Translations for New Developments
|
||||
|
||||
## Status
|
||||
- [x] Analysis of new features needing translation
|
||||
- [x] Update Italian Translations (it)
|
||||
- [x] Update English Translations (en)
|
||||
- [x] Verification
|
||||
|
||||
## Details
|
||||
Verified recent developments:
|
||||
1. **Warehouse - Categories**: New management of article categories.
|
||||
2. **Communications**: Email configuration and logs.
|
||||
3. **Training**: New module for courses and training sessions.
|
||||
|
||||
I will scan these modules for `t()` calls and update the `translation.json` files in `public/locales/it` and `public/locales/en`.
|
||||
|
||||
## Work Done
|
||||
- **Warehouse Categories**: Updated `CategoriesPage.tsx` to use `useTranslation`. Added keys for titles, buttons, fields, and dialogs in both IT and EN locales.
|
||||
- **Communications**: Updated `SettingsPage.tsx` and `LogsPage.tsx` to use `useTranslation`. Added complete set of keys for settings, fields, actions, messages and log columns in both IT and EN locales.
|
||||
- **Components**: Updated `Sidebar.tsx`, `SearchBar.tsx` to use full translations. Added `apps.core.title` and ensure `categories` is available in menu.
|
||||
- **Training**: Training module files were not found in the current workspace, so no translations were applied for this module yet. Suggest to review separately when module is available.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Analisi Funzionale e Piano di Implementazione: Modulo Formazione Obbligatoria
|
||||
|
||||
## 1. Introduzione e Obiettivi
|
||||
La presente analisi definisce le specifiche per l'estensione del sistema **Zentral** (progetto "OBIS" nel contesto cliente) con un modulo dedicato alla **Gestione della Formazione Obbligatoria**.
|
||||
L'obiettivo è integrare nativamente la gestione di aziende, lavoratori, corsi, scadenze e attestati, automatizzando il calcolo delle validità e il workflow di notifica ai referenti aziendali.
|
||||
|
||||
## 2. Requisiti Funzionali
|
||||
|
||||
### 2.1 Gestione Anagrafiche
|
||||
Il sistema deve sfruttare le entità esistenti estendendone la logica di presentazione e filtraggio.
|
||||
- **Aziende e Sedi**: Mapping su `Cliente`.
|
||||
- **Funzionalità**: Attivazione/disattivazione (campo `Attivo`), storicizzazione (implicita nel non cancellare i dati), gestione sedi (già presente o gestibile tramite indirizzi multipli/destinazioni o clienti gerarchici. *Decisione*: Usare `Cliente` standard. Se necessario "Sede", si useranno i campi indirizzo o clienti collegati).
|
||||
- **Lavoratori**: Mapping su `ClienteContatto`.
|
||||
- **Funzionalità**: Ricerca trasversale (Global Search), filtri per Azienda, Ruolo, Stato Formativo.
|
||||
- **Dati**: Nome, Cognome, Ruolo (es. "Saldatore", "Impiegato"), Email, Telefono.
|
||||
|
||||
### 2.2 Catalogo Corsi
|
||||
Il catalogo corsi è il "motore" delle regole di scadenza.
|
||||
- **Mapping**: `Articolo` con Categoria "Formazione".
|
||||
- **Configurazione**:
|
||||
- **Tipologia**: Definita tramite sottocategorie merceologiche (es. Sicurezza > Basso Rischio).
|
||||
- **Validità**: Campo `GiorniValidita` (già implementato) per calcolo automatico scadenza.
|
||||
- **Logica Aggiornamento**: Definizione se un corso è aggiornamento di un altro (facoltativo, logica avanzata).
|
||||
|
||||
### 2.3 Registro Formazione ed Eventi
|
||||
Centralizzazione dello storico formativo.
|
||||
- **Mapping**: `TrainingRecord`.
|
||||
- **Funzionalità**:
|
||||
- Registrazione partecipazione lavoratore a corso.
|
||||
- **Calcolo Stati**:
|
||||
- *Valido*: Corso effettuato e non scaduto.
|
||||
- *In Pre-scadenza*: Meno di X giorni alla scadenza (configurabile, es. 30 o 60 gg).
|
||||
- *Scaduto*: Data odierna > Data Scadenza.
|
||||
- **Attestati**: Upload PDF/JPG, anteprima, download, archiviazione.
|
||||
|
||||
### 2.4 Scadenzario Interattivo (Dashboard)
|
||||
Strumento principale per l'operatore.
|
||||
- **Visualizzazione**: Tabellare avanzata (Data Grid).
|
||||
- **Colonne Chiave**: Lavoratore, Azienda, Corso, Data Esecuzione, Data Scadenza, Stato, Azioni.
|
||||
- **Filtri**:
|
||||
- Per Azienda/Sede.
|
||||
- Per Tipologia Corso.
|
||||
- Range Date Scadenza.
|
||||
- Stato (Mostra solo Scaduti/In Scadenza).
|
||||
- **Export**: Funzione diretta "Esporta in Excel" della vista filtrata.
|
||||
|
||||
### 2.5 Sistema di Notifiche (Workflow Approvativo)
|
||||
Il sistema non deve inviare email "a pioggia" ai lavoratori, ma notifiche controllate ai referenti.
|
||||
- **Target**: Referente Aziendale (identificato nel `Cliente` o un `ClienteContatto` specifico marcato come "Referente Formazione").
|
||||
- **Tipologie**:
|
||||
- *Pre-scadenza*: Avviso X giorni prima.
|
||||
- *Scadenza*: Avviso il giorno stesso o settimana stessa.
|
||||
- *Post-scadenza*: Sollecito.
|
||||
- **Coda di Invio (Queue)**:
|
||||
- Le email **non** partono subito. Vengono generate in stato `Pending` in una tabella dedicata (`TrainingNotificationQueue`).
|
||||
- **Interfaccia di Review**: L'operatore vede le email pronte, può selezionarle, modificarle (opzionale) e approvarne l'invio.
|
||||
- **Template**:
|
||||
- Supporto per template standard (Oggetto e Corpo configurabili con placeholder `{Azienda}`, `{Lavoratore}`, `{Corso}`, `{Scadenza}`).
|
||||
|
||||
### 2.6 Import/Export Anagrafiche
|
||||
- **Import Massivo**: Upload file Excel per popolare/aggiornare `ClienteContatto` (Lavoratori) e storico `TrainingRecord`.
|
||||
- **Export E-learning**: Esportazione CSV/XLS su tracciati specifici (da definire, genericamente "Campi Anagrafici Base") per import su piattaforme esterne.
|
||||
|
||||
---
|
||||
|
||||
## 3. Piano di Implementazione Tecnico
|
||||
|
||||
### Phase 1: Backend Extension & Data Model
|
||||
1. **Entities**:
|
||||
- Verificare `TrainingRecord` (già esistente).
|
||||
- Creare `TrainingNotification` (Queue):
|
||||
- `Id`, `TrainingRecordId`, `RecipientEmail`, `Subject`, `Body`, `ScheduledDate`, `SentDate`, `Status` (Pending, Approved, Sent, Error).
|
||||
- Creare `ImportJob` (opzionale, o gestione diretta API).
|
||||
2. **API Controllers**:
|
||||
- `TrainingController`:
|
||||
- Endpoint `GetDeadlines`: Query complessa con filtri, paginazione ordinamento.
|
||||
- Endpoint `ExportDeadlines`: Generazione Excel.
|
||||
- Endpoint `ImportData`: Parsing Excel e bulk insert.
|
||||
- Endpoint `GenerateNotifications`: Job (o trigger) per popolare la coda notifiche in base alle scadenze.
|
||||
- Endpoint `SendNotifications`: Invio massivo delle notifiche approvate.
|
||||
|
||||
### Phase 2: Frontend Implementation (App `training`)
|
||||
1. **Views (Pagine)**:
|
||||
- **Scadenzario (`TrainingDeadlinesPage`)**:
|
||||
- Datagrid avanzata (libreria UI o custom table con filtri).
|
||||
- Bottone "Esporta Excel".
|
||||
- **Code Notifiche (`NotificationCenterPage`)**:
|
||||
- Lista email in attesa.
|
||||
- Checkbox selezione multipla -> Azione "Approva e Invia".
|
||||
- Preview email side-by-side.
|
||||
- **Registro Lavoratori (`WorkersRegistryPage`)**:
|
||||
- Vista incentrata sui `ClienteContatto` con focus formazione (colonne: Ultimi corsi, Stato generale).
|
||||
- **Import/Export Utility (`DataExchangePage`)**:
|
||||
- Upload file Excel, mapping colonne (semplificato), log risultati import.
|
||||
|
||||
### Phase 3: Integration & Logic
|
||||
1. **Notification Logic**:
|
||||
- Service che scansiona `TrainingRecord` ogni notte (o on-demand), calcola scadenze, controlla se notifica già generata, crea record in `TrainingNotification`.
|
||||
- Logica di raggruppamento: Se un'azienda ha 10 lavoratori in scadenza, inviare 1 email cumulativa al referente o 10 email separate? *Specifiche attuali: "email... indirizzate ai referenti... non ai singoli lavoratori"*.
|
||||
- *Decisione Progettuale*: **Email Raggruppata per Referente**. Il sistema deve raggruppare le scadenze per Azienda e generare una sola notifica con la lista dei lavoratori in scadenza.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nuove Rotte e Struttura File (Preview)
|
||||
|
||||
### Backend
|
||||
- `src/backend/Zentral.Domain/Entities/Training/TrainingNotification.cs`
|
||||
- `src/backend/Zentral.API/Modules/Training/Controllers/TrainingNotificationsController.cs`
|
||||
- `src/backend/Zentral.API/Modules/Training/Services/NotificationGeneratorService.cs`
|
||||
- `src/backend/Zentral.API/Modules/Training/Services/ExcelImportService.cs`
|
||||
|
||||
### Frontend
|
||||
- `src/frontend/src/apps/training/pages/TrainingDeadlinesPage.tsx`
|
||||
- `src/frontend/src/apps/training/pages/NotificationCenterPage.tsx`
|
||||
- `src/frontend/src/apps/training/pages/WorkersRegistryPage.tsx`
|
||||
- `src/frontend/src/apps/training/pages/DataExchangePage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 5. Note Operative
|
||||
- Utilizzare libreria `EPPlus` o `ClosedXML` lato server per Excel, o `SheetJS` lato client se l'export è puramente visivo (preferibile server-side per grandi moli di dati).
|
||||
- Per le Importazioni: Validazione rigorosa Codici Fiscali o Email univoche per evitare duplicati anagrafiche.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Implementazione Modulo Formazione Obbligatoria (Mandatory Training)
|
||||
|
||||
## Stato: Completato
|
||||
|
||||
Ho completato l'implementazione del modulo Formazione Obbligatoria seguendo le specifiche definite in `2025-12-13-164500_mandatory_training_specs.md`.
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### Backend
|
||||
1. **Entities**:
|
||||
- Creata `TrainingNotification` in `Zentral.Domain` per gestire la coda di notifiche.
|
||||
- Aggiornato `ZentralDbContext` (DbSet).
|
||||
- Creata migrazione `AddTrainingNotifications`.
|
||||
2. **Services**:
|
||||
- Creato `TrainingNotificationService`:
|
||||
- Logica `GenerateNotificationsAsync`: raggruppa scadenze per Cliente, crea notifiche `Pending`.
|
||||
- Logica `SendApprovedNotificationsAsync`: invia email per notifiche `Approved`.
|
||||
- Generazione corpo email HTML con tabella riepilogativa.
|
||||
- Registrato servizio in `Program.cs`.
|
||||
3. **Controllers**:
|
||||
- Creato `TrainingNotificationsController`:
|
||||
- Endpoints per Listing, Generazione, Approvazione, Modifica e Invio.
|
||||
- Aggiornato `AppService` (verifica esistenza modulo, usato nei service).
|
||||
|
||||
### Frontend
|
||||
1. **Pagine Nuove (App Training)**:
|
||||
- `TrainingDeadlinesPage`: Scadenzario tabellare con indicatori di stato.
|
||||
- `NotificationCenterPage`: Gestione coda notifiche (Approvazione/Modifica/Invio).
|
||||
- `WorkersRegistryPage`: Registro lavoratori con stato formativo aggregato.
|
||||
- `DataExchangePage`: Placeholder per Import/Export.
|
||||
2. **Navigazione**:
|
||||
- Aggiornato `Sidebar.tsx` con le nuove voci di menu sotto "Formazione" ("Lavoratori", "Scadenze", "Notifiche", "Import/Export").
|
||||
- Aggiornato `routes.tsx` con le relative rotte.
|
||||
|
||||
## Note per il Testing
|
||||
- Per testare le notifiche:
|
||||
1. Andare in "Notifiche".
|
||||
2. Cliccare "Genera".
|
||||
3. Verificare la creazione di notifiche per le aziende con scadenze.
|
||||
4. Approvare una notifica.
|
||||
5. Cliccare "Invia Approvate".
|
||||
- Assicurarsi che il modulo "Comunicazioni" sia attivo e configurato (SMTP).
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.API.Apps.Communications.Dtos;
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Zentral.API.Apps.Communications.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/communications")]
|
||||
public class CommunicationsController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly IEmailSender _emailSender;
|
||||
|
||||
public CommunicationsController(ZentralDbContext context, IEmailSender emailSender)
|
||||
{
|
||||
_context = context;
|
||||
_emailSender = emailSender;
|
||||
}
|
||||
|
||||
[HttpGet("config")]
|
||||
public async Task<ActionResult<SmtpConfigDto>> GetConfig()
|
||||
{
|
||||
var configs = await _context.Configurazioni
|
||||
.Where(c => c.Chiave.StartsWith("SMTP_") || c.Chiave == "EMAIL_PROVIDER" || c.Chiave == "RESEND_API_KEY")
|
||||
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
|
||||
|
||||
var dto = new SmtpConfigDto
|
||||
{
|
||||
Host = GetValue(configs, "SMTP_HOST"),
|
||||
Port = int.Parse(GetValue(configs, "SMTP_PORT", "587")),
|
||||
User = GetValue(configs, "SMTP_USER"),
|
||||
Password = GetValue(configs, "SMTP_PASS"),
|
||||
EnableSsl = bool.Parse(GetValue(configs, "SMTP_SSL", "false")),
|
||||
FromEmail = GetValue(configs, "SMTP_FROM_EMAIL"),
|
||||
FromName = GetValue(configs, "SMTP_FROM_NAME"),
|
||||
Provider = GetValue(configs, "EMAIL_PROVIDER", "smtp"),
|
||||
ResendApiKey = GetValue(configs, "RESEND_API_KEY")
|
||||
};
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost("config")]
|
||||
public async Task<ActionResult> SaveConfig(SmtpConfigDto dto)
|
||||
{
|
||||
await SetConfig("SMTP_HOST", dto.Host);
|
||||
await SetConfig("SMTP_PORT", dto.Port.ToString());
|
||||
await SetConfig("SMTP_USER", dto.User);
|
||||
await SetConfig("SMTP_PASS", dto.Password);
|
||||
await SetConfig("SMTP_SSL", dto.EnableSsl.ToString().ToLower());
|
||||
await SetConfig("SMTP_FROM_EMAIL", dto.FromEmail);
|
||||
await SetConfig("SMTP_FROM_NAME", dto.FromName);
|
||||
|
||||
await SetConfig("EMAIL_PROVIDER", dto.Provider);
|
||||
await SetConfig("RESEND_API_KEY", dto.ResendApiKey);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("send-test")]
|
||||
public async Task<ActionResult> SendTestEmail(TestEmailDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _emailSender.SendEmailAsync(dto.To, dto.Subject, dto.Body);
|
||||
return Ok(new { message = "Email send process initiated. Check logs for status." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("logs")]
|
||||
public async Task<ActionResult<List<EmailLogDto>>> GetLogs([FromQuery] int limit = 50)
|
||||
{
|
||||
var logs = await _context.EmailLogs
|
||||
.OrderByDescending(l => l.SentDate)
|
||||
.Take(limit)
|
||||
.Select(l => new EmailLogDto
|
||||
{
|
||||
Id = l.Id,
|
||||
SentDate = l.SentDate,
|
||||
Sender = l.Sender,
|
||||
Recipient = l.Recipient,
|
||||
Subject = l.Subject,
|
||||
Status = l.Status,
|
||||
ErrorMessage = l.ErrorMessage
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
private string GetValue(Dictionary<string, string?> dict, string key, string def = "")
|
||||
{
|
||||
return dict.ContainsKey(key) && dict[key] != null ? dict[key]! : def;
|
||||
}
|
||||
|
||||
private async Task SetConfig(string key, string? value)
|
||||
{
|
||||
var config = await _context.Configurazioni.FirstOrDefaultAsync(c => c.Chiave == key);
|
||||
if (config == null)
|
||||
{
|
||||
config = new Configurazione { Chiave = key, CreatedAt = DateTime.UtcNow, CreatedBy = User.FindFirstValue(ClaimTypes.Name) ?? "System" };
|
||||
_context.Configurazioni.Add(config);
|
||||
}
|
||||
config.Valore = value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = User.FindFirstValue(ClaimTypes.Name) ?? "System";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace Zentral.API.Apps.Communications.Dtos;
|
||||
|
||||
public class EmailLogDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime SentDate { get; set; }
|
||||
public string Sender { get; set; } = string.Empty;
|
||||
public string Recipient { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Zentral.API.Apps.Communications.Dtos;
|
||||
|
||||
public class SmtpConfigDto
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public int Port { get; set; } = 587;
|
||||
public string User { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public bool EnableSsl { get; set; } = false;
|
||||
public string FromEmail { get; set; } = string.Empty;
|
||||
public string FromName { get; set; } = string.Empty;
|
||||
|
||||
// New fields for Resend support
|
||||
public string Provider { get; set; } = "smtp"; // "smtp" or "resend"
|
||||
public string ResendApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Zentral.API.Apps.Communications.Dtos;
|
||||
|
||||
public class TestEmailDto
|
||||
{
|
||||
public string To { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = "Test Email from Zentral";
|
||||
public string Body { get; set; } = "This is a test email sent from Zentral Communications Module.";
|
||||
}
|
||||
@@ -28,6 +28,10 @@ public interface IWarehouseService
|
||||
Task<WarehouseArticleCategory> UpdateCategoryAsync(WarehouseArticleCategory category);
|
||||
Task DeleteCategoryAsync(int id);
|
||||
|
||||
// ===============================================
|
||||
// GRUPPI MERCEOLOGICI
|
||||
// ===============================================
|
||||
|
||||
// ===============================================
|
||||
// MAGAZZINI
|
||||
// ===============================================
|
||||
|
||||
@@ -60,6 +60,7 @@ public class WarehouseService : IWarehouseService
|
||||
if (filter.CategoryId.HasValue)
|
||||
query = query.Where(a => a.CategoryId == filter.CategoryId);
|
||||
|
||||
|
||||
if (filter.IsActive.HasValue)
|
||||
query = query.Where(a => a.IsActive == filter.IsActive);
|
||||
|
||||
@@ -336,6 +337,7 @@ public class WarehouseService : IWarehouseService
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region Magazzini
|
||||
|
||||
public async Task<List<WarehouseLocation>> GetWarehousesAsync(bool includeInactive = false)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.API.Apps.Purchases.Services;
|
||||
using Zentral.API.Apps.Sales.Services;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Zentral.Infrastructure.Services;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -19,6 +22,7 @@ builder.Services.AddDbContext<ZentralDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
|
||||
// Services
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddScoped<EventoCostiService>();
|
||||
builder.Services.AddScoped<DemoDataService>();
|
||||
builder.Services.AddScoped<ReportGeneratorService>();
|
||||
@@ -28,6 +32,9 @@ builder.Services.AddScoped<AutoCodeService>();
|
||||
builder.Services.AddScoped<CustomFieldService>();
|
||||
builder.Services.AddSingleton<DataNotificationService>();
|
||||
|
||||
// Communications Module Services
|
||||
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
|
||||
|
||||
// Warehouse Module Services
|
||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||
|
||||
@@ -42,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();
|
||||
|
||||
|
||||
@@ -521,6 +521,34 @@ public class AppService
|
||||
RoutePath = "/report-designer",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "communications",
|
||||
Name = "Comunicazioni",
|
||||
Description = "Gestione invio mail, chat interna e condivisione risorse",
|
||||
Icon = "Email",
|
||||
BasePrice = 1000m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 90,
|
||||
IsCore = false,
|
||||
RoutePath = "/communications",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
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,14 @@
|
||||
using System;
|
||||
using Zentral.Domain;
|
||||
|
||||
namespace Zentral.Domain.Entities.Communications;
|
||||
|
||||
public class EmailLog : BaseEntity
|
||||
{
|
||||
public DateTime SentDate { get; set; }
|
||||
public string Sender { get; set; } = string.Empty;
|
||||
public string Recipient { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty; // "Success", "Failed"
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,11 @@ public class WarehouseArticle : BaseEntity
|
||||
/// </summary>
|
||||
public int? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gruppo merceologico
|
||||
/// </summary>
|
||||
public int? ProductGroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unità di misura principale (es. PZ, KG, LT, MT)
|
||||
/// </summary>
|
||||
|
||||
7
src/backend/Zentral.Domain/Interfaces/IEmailSender.cs
Normal file
7
src/backend/Zentral.Domain/Interfaces/IEmailSender.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Zentral.Domain.Interfaces;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task SendEmailAsync(string to, string subject, string body, bool isHtml = true);
|
||||
Task SendEmailAsync(string to, string subject, string body, List<string> attachments, bool isHtml = true);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using Zentral.Domain.Entities.Purchases;
|
||||
using Zentral.Domain.Entities.Sales;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Zentral.Domain.Entities.HR;
|
||||
using Zentral.Domain.Entities.Communications;
|
||||
using Zentral.Domain.Entities.Training;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.Infrastructure.Data;
|
||||
@@ -94,6 +96,14 @@ public class ZentralDbContext : DbContext
|
||||
public DbSet<Assenza> Assenze => Set<Assenza>();
|
||||
public DbSet<Pagamento> Pagamenti => Set<Pagamento>();
|
||||
public DbSet<Rimborso> Rimborsi => Set<Rimborso>();
|
||||
|
||||
// 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)
|
||||
{
|
||||
@@ -389,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
|
||||
// ===============================================
|
||||
@@ -441,6 +480,7 @@ public class ZentralDbContext : DbContext
|
||||
.WithMany(c => c.Articles)
|
||||
.HasForeignKey(e => e.CategoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
});
|
||||
|
||||
// ArticleBatch
|
||||
@@ -989,5 +1029,16 @@ public class ZentralDbContext : DbContext
|
||||
.HasForeignKey(e => e.ArticleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ===============================================
|
||||
// COMMUNICATIONS MODULE ENTITIES
|
||||
// ===============================================
|
||||
modelBuilder.Entity<EmailLog>(entity =>
|
||||
{
|
||||
entity.ToTable("EmailLogs");
|
||||
entity.HasIndex(e => e.SentDate);
|
||||
entity.HasIndex(e => e.Status);
|
||||
entity.HasIndex(e => e.Recipient);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
4758
src/backend/Zentral.Infrastructure/Migrations/20251212105451_UpdateCommunicationsModule.Designer.cs
generated
Normal file
4758
src/backend/Zentral.Infrastructure/Migrations/20251212105451_UpdateCommunicationsModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateCommunicationsModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmailLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SentDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Sender = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Recipient = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Subject = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmailLogs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailLogs_Recipient",
|
||||
table: "EmailLogs",
|
||||
column: "Recipient");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailLogs_SentDate",
|
||||
table: "EmailLogs",
|
||||
column: "SentDate");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailLogs_Status",
|
||||
table: "EmailLogs",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmailLogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
4823
src/backend/Zentral.Infrastructure/Migrations/20251212115332_AddWarehouseProductGroups.Designer.cs
generated
Normal file
4823
src/backend/Zentral.Infrastructure/Migrations/20251212115332_AddWarehouseProductGroups.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWarehouseProductGroups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WarehouseProductGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Notes = 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_WarehouseProductGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseArticles_ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
column: "ProductGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseProductGroups_Code",
|
||||
table: "WarehouseProductGroups",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseProductGroups_IsActive",
|
||||
table: "WarehouseProductGroups",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
column: "ProductGroupId",
|
||||
principalTable: "WarehouseProductGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WarehouseProductGroups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_WarehouseArticles_ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
}
|
||||
}
|
||||
}
|
||||
4761
src/backend/Zentral.Infrastructure/Migrations/20251212122107_RemoveWarehouseProductGroups.Designer.cs
generated
Normal file
4761
src/backend/Zentral.Infrastructure/Migrations/20251212122107_RemoveWarehouseProductGroups.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveWarehouseProductGroups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WarehouseProductGroups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_WarehouseArticles_ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WarehouseProductGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WarehouseProductGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseArticles_ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
column: "ProductGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseProductGroups_Code",
|
||||
table: "WarehouseProductGroups",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseProductGroups_IsActive",
|
||||
table: "WarehouseProductGroups",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
column: "ProductGroupId",
|
||||
principalTable: "WarehouseProductGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
@@ -418,6 +472,60 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.ToTable("CodiciCategoria");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Communications.EmailLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomFieldsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Recipient")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Sender")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("SentDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Recipient");
|
||||
|
||||
b.HasIndex("SentDate");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("EmailLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Configurazione", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2541,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")
|
||||
@@ -3621,6 +3836,9 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ProductGroupId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("ReorderPoint")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -3867,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")
|
||||
@@ -4249,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")
|
||||
@@ -4531,6 +4788,8 @@ namespace Zentral.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Cliente", b =>
|
||||
{
|
||||
b.Navigation("Contatti");
|
||||
|
||||
b.Navigation("Eventi");
|
||||
|
||||
b.Navigation("SalesOrders");
|
||||
|
||||
199
src/backend/Zentral.Infrastructure/Services/SmtpEmailSender.cs
Normal file
199
src/backend/Zentral.Infrastructure/Services/SmtpEmailSender.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MimeKit;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Net.Http;
|
||||
using Zentral.Domain.Entities.Communications;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Zentral.Infrastructure.Data;
|
||||
|
||||
namespace Zentral.Infrastructure.Services;
|
||||
|
||||
public class SmtpEmailSender : IEmailSender
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public SmtpEmailSender(IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true)
|
||||
{
|
||||
await SendEmailAsync(to, subject, body, new List<string>(), isHtml);
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string to, string subject, string body, List<string> attachments, bool isHtml = true)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<ZentralDbContext>();
|
||||
|
||||
// 1. Get Configuration
|
||||
var configs = await context.Configurazioni
|
||||
.Where(c => c.Chiave.StartsWith("SMTP_") || c.Chiave == "EMAIL_PROVIDER" || c.Chiave == "RESEND_API_KEY")
|
||||
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
|
||||
|
||||
var provider = GetConfig(configs, "EMAIL_PROVIDER", "smtp");
|
||||
|
||||
if (provider.ToLower() == "resend")
|
||||
{
|
||||
await SendViaResendAsync(context, to, subject, body, attachments, isHtml, configs);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendViaSmtpAsync(context, to, subject, body, attachments, isHtml, configs);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendViaResendAsync(ZentralDbContext context, string to, string subject, string body, List<string> attachments, bool isHtml, Dictionary<string, string?> configs)
|
||||
{
|
||||
var apiKey = GetConfig(configs, "RESEND_API_KEY");
|
||||
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL"); // Resend often requires a verified domain, but we reuse the field
|
||||
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", "Resend API Key not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
var request = new
|
||||
{
|
||||
from = $"{fromName} <{fromEmail}>",
|
||||
to = new[] { to },
|
||||
subject = subject,
|
||||
html = isHtml ? body : null,
|
||||
text = !isHtml ? body : null,
|
||||
attachments = attachments.Select(a => {
|
||||
var bytes = System.IO.File.ReadAllBytes(a);
|
||||
return new
|
||||
{
|
||||
filename = System.IO.Path.GetFileName(a),
|
||||
content = Convert.ToBase64String(bytes)
|
||||
};
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("https://api.resend.com/emails", request);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Success", "Via Resend");
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", $"Resend Error: {errorContent}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendViaSmtpAsync(ZentralDbContext context, string to, string subject, string body, List<string> attachments, bool isHtml, Dictionary<string, string?> configs)
|
||||
{
|
||||
var host = GetConfig(configs, "SMTP_HOST");
|
||||
var portStr = GetConfig(configs, "SMTP_PORT", "587");
|
||||
var user = GetConfig(configs, "SMTP_USER");
|
||||
var pass = GetConfig(configs, "SMTP_PASS");
|
||||
var sslStr = GetConfig(configs, "SMTP_SSL", "false");
|
||||
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL", user);
|
||||
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
|
||||
|
||||
if (string.IsNullOrEmpty(host))
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", "SMTP Host not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
int.TryParse(portStr, out int port);
|
||||
bool.TryParse(sslStr, out bool useSsl);
|
||||
|
||||
// 2. Prepare Message
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(fromName, fromEmail));
|
||||
message.To.Add(MailboxAddress.Parse(to));
|
||||
message.Subject = subject;
|
||||
|
||||
var builder = new BodyBuilder();
|
||||
if (isHtml)
|
||||
builder.HtmlBody = body;
|
||||
else
|
||||
builder.TextBody = body;
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (System.IO.File.Exists(attachment))
|
||||
{
|
||||
builder.Attachments.Add(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
message.Body = builder.ToMessageBody();
|
||||
|
||||
// 3. Send
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
if (port == 465)
|
||||
await client.ConnectAsync(host, port, SecureSocketOptions.SslOnConnect);
|
||||
else if (port == 587)
|
||||
await client.ConnectAsync(host, port, SecureSocketOptions.StartTls);
|
||||
else
|
||||
await client.ConnectAsync(host, port, SecureSocketOptions.Auto);
|
||||
|
||||
if (!string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(pass))
|
||||
{
|
||||
await client.AuthenticateAsync(user, pass);
|
||||
}
|
||||
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Success", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetConfig(Dictionary<string, string?> configs, string key, string defaultValue = "")
|
||||
{
|
||||
return configs.ContainsKey(key) && !string.IsNullOrEmpty(configs[key]) ? configs[key]! : defaultValue;
|
||||
}
|
||||
|
||||
private async Task LogResultAsync(ZentralDbContext context, string from, string to, string subject, string status, string? error)
|
||||
{
|
||||
var log = new EmailLog
|
||||
{
|
||||
SentDate = DateTime.UtcNow,
|
||||
Sender = from,
|
||||
Recipient = to,
|
||||
Subject = subject,
|
||||
Status = status,
|
||||
ErrorMessage = error,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = "System"
|
||||
};
|
||||
|
||||
context.EmailLogs.Add(log);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
||||
<PackageReference Include="MailKit" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -62,9 +62,11 @@
|
||||
"cycles": "Cycles",
|
||||
"mrp": "MRP",
|
||||
"administration": "Administration",
|
||||
"emailConfig": "Email Configuration",
|
||||
"movements": "Movements",
|
||||
"stock": "Stock",
|
||||
"inventory": "Inventory"
|
||||
"inventory": "Inventory",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"navigation": {
|
||||
"searchPlaceholder": "Search...",
|
||||
@@ -284,12 +286,33 @@
|
||||
"confermato": "Confirmed"
|
||||
},
|
||||
"apps": {
|
||||
"core": {
|
||||
"title": "Zentral"
|
||||
},
|
||||
"warehouse": {
|
||||
"title": "Warehouse Management",
|
||||
"inventory": "Inventory",
|
||||
"movements": "Movements",
|
||||
"stock": "Stock",
|
||||
"categories": "Categories"
|
||||
"categories": {
|
||||
"title": "Article Categories",
|
||||
"new": "New Category",
|
||||
"edit": "Edit Category",
|
||||
"empty": "No categories found",
|
||||
"newParams": {
|
||||
"root": "New Root Category"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"sortOrder": "Sort Order",
|
||||
"active": "Active"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Confirmation",
|
||||
"content": "Are you sure you want to delete this category? This operation cannot be undone. If the category contains subcategories or articles, it may not be possible to delete it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"hr": {
|
||||
"title": "Human Resources",
|
||||
@@ -299,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",
|
||||
@@ -399,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"
|
||||
}
|
||||
},
|
||||
@@ -1551,5 +1592,56 @@
|
||||
"permesso": "Permit",
|
||||
"altro": "Other"
|
||||
}
|
||||
},
|
||||
"communications": {
|
||||
"settings": {
|
||||
"title": "Email Configuration",
|
||||
"fields": {
|
||||
"provider": "Provider",
|
||||
"host": "SMTP Host",
|
||||
"port": "Port",
|
||||
"user": "Username",
|
||||
"password": "Password",
|
||||
"ssl": "Enable SSL/TLS",
|
||||
"apiKey": "Resend API Key",
|
||||
"fromEmail": "From Email",
|
||||
"fromName": "From Name"
|
||||
},
|
||||
"helpers": {
|
||||
"apiKey": "Get your API Key at"
|
||||
},
|
||||
"sections": {
|
||||
"defaultSender": "Default Sender"
|
||||
},
|
||||
"actions": {
|
||||
"testConnection": "Test Connection",
|
||||
"sendTest": "Send Test"
|
||||
},
|
||||
"testStats": {
|
||||
"title": "Test Email",
|
||||
"recipient": "Recipient",
|
||||
"subject": "Subject"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "Error loading configuration",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveError": "Error saving configuration",
|
||||
"recipientRequired": "Recipient email is required for test",
|
||||
"testSuccess": "Test email sent successfully",
|
||||
"testError": "Error sending test email"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Email Logs",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"date": "Date",
|
||||
"status": "Status",
|
||||
"sender": "Sender",
|
||||
"recipient": "Recipient",
|
||||
"subject": "Subject",
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@
|
||||
"preview": "Anteprima",
|
||||
"none": "Nessuno",
|
||||
"view": "Dettaglio",
|
||||
"copy": "Copia"
|
||||
"copy": "Copia",
|
||||
"category": "Categoria"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -58,9 +59,12 @@
|
||||
"cycles": "Cicli",
|
||||
"mrp": "MRP",
|
||||
"administration": "Amministrazione",
|
||||
"emailConfig": "Configurazione Email",
|
||||
"movements": "Movimenti",
|
||||
"stock": "Giacenze",
|
||||
"inventory": "Inventario"
|
||||
"inventory": "Inventario",
|
||||
"categories": "Categorie",
|
||||
"training": "Formazione"
|
||||
},
|
||||
"navigation": {
|
||||
"searchPlaceholder": "Cerca...",
|
||||
@@ -207,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",
|
||||
@@ -280,12 +288,33 @@
|
||||
"confermato": "Confermato"
|
||||
},
|
||||
"apps": {
|
||||
"core": {
|
||||
"title": "Zentral"
|
||||
},
|
||||
"warehouse": {
|
||||
"title": "Gestione Magazzino",
|
||||
"inventory": "Inventario",
|
||||
"movements": "Movimenti",
|
||||
"stock": "Giacenze",
|
||||
"categories": "Categorie"
|
||||
"categories": {
|
||||
"title": "Categorie Articoli",
|
||||
"new": "Nuova Categoria",
|
||||
"edit": "Modifica Categoria",
|
||||
"empty": "Nessuna categoria trovata",
|
||||
"newParams": {
|
||||
"root": "Nuova Categoria Root"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"description": "Descrizione",
|
||||
"sortOrder": "Ordinamento",
|
||||
"active": "Attivo"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Conferma Eliminazione",
|
||||
"content": "Sei sicuro di voler eliminare questa categoria? L'operazione non può essere annullata. Se la categoria contiene sottocategorie o articoli, potrebbe non essere possibile eliminarla."
|
||||
}
|
||||
}
|
||||
},
|
||||
"hr": {
|
||||
"title": "Gestione Personale",
|
||||
@@ -295,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",
|
||||
@@ -396,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"
|
||||
}
|
||||
},
|
||||
@@ -1632,5 +1679,83 @@
|
||||
"permesso": "Permesso",
|
||||
"altro": "Altro"
|
||||
}
|
||||
},
|
||||
"communications": {
|
||||
"settings": {
|
||||
"title": "Configurazione Email",
|
||||
"fields": {
|
||||
"provider": "Provider",
|
||||
"host": "SMTP Host",
|
||||
"port": "Porta",
|
||||
"user": "Username",
|
||||
"password": "Password",
|
||||
"ssl": "Abilita SSL/TLS",
|
||||
"apiKey": "Resend API Key",
|
||||
"fromEmail": "Email Mittente",
|
||||
"fromName": "Nome Mittente"
|
||||
},
|
||||
"helpers": {
|
||||
"apiKey": "Ottieni la tua API Key su"
|
||||
},
|
||||
"sections": {
|
||||
"defaultSender": "Mittente Default"
|
||||
},
|
||||
"actions": {
|
||||
"testConnection": "Test Connessione",
|
||||
"sendTest": "Invia Test"
|
||||
},
|
||||
"testStats": {
|
||||
"title": "Test Email",
|
||||
"recipient": "Destinatario",
|
||||
"subject": "Oggetto"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "Errore nel caricamento configurazione",
|
||||
"saveSuccess": "Configurazione salvata con successo",
|
||||
"saveError": "Errore nel salvataggio configurazione",
|
||||
"recipientRequired": "Email destinatario obbligatoria per il test",
|
||||
"testSuccess": "Email di test inviata con successo",
|
||||
"testError": "Errore nell'invio email di test"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Log Email",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"date": "Data",
|
||||
"status": "Stato",
|
||||
"sender": "Mittente",
|
||||
"recipient": "Destinatario",
|
||||
"subject": "Oggetto",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,14 @@ import SalesRoutes from "./apps/sales/routes";
|
||||
import ProductionRoutes from "./apps/production/routes";
|
||||
import EventsRoutes from "./apps/events/routes";
|
||||
import HRRoutes from "./apps/hr/routes";
|
||||
import CommunicationsRoutes from "./apps/communications/routes";
|
||||
import TrainingRoutes from "./apps/training/routes";
|
||||
import { AppGuard } from "./components/AppGuard";
|
||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||
import { AppProvider } from "./contexts/AppContext";
|
||||
import { TabProvider } from "./contexts/TabContext";
|
||||
import EmailConfigPage from "./apps/communications/pages/SettingsPage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -81,6 +84,10 @@ function App() {
|
||||
path="admin/custom-fields"
|
||||
element={<CustomFieldsAdminPage />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/email-config"
|
||||
element={<EmailConfigPage />}
|
||||
/>
|
||||
{/* Warehouse Module */}
|
||||
<Route
|
||||
path="warehouse/*"
|
||||
@@ -135,6 +142,24 @@ function App() {
|
||||
</AppGuard>
|
||||
}
|
||||
/>
|
||||
{/* Communications Module */}
|
||||
<Route
|
||||
path="communications/*"
|
||||
element={
|
||||
<AppGuard appCode="communications">
|
||||
<CommunicationsRoutes />
|
||||
</AppGuard>
|
||||
}
|
||||
/>
|
||||
{/* Training Module */}
|
||||
<Route
|
||||
path="training/*"
|
||||
element={
|
||||
<AppGuard appCode="training">
|
||||
<TrainingRoutes />
|
||||
</AppGuard>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</TabProvider>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Box, Paper, Tab, Tabs } from "@mui/material";
|
||||
|
||||
export default function CommunicationsLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const getActiveTab = () => {
|
||||
const path = location.pathname;
|
||||
if (path.includes("/communications/logs")) return "/communications/logs";
|
||||
return "/communications/settings";
|
||||
};
|
||||
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: string) => {
|
||||
navigate(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Tabs
|
||||
value={getActiveTab()}
|
||||
onChange={handleChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
>
|
||||
<Tab label="Configurazione" value="/communications/settings" />
|
||||
<Tab label="Logs" value="/communications/logs" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Box sx={{ flex: 1, overflow: "auto" }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
71
src/frontend/src/apps/communications/pages/LogsPage.tsx
Normal file
71
src/frontend/src/apps/communications/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DataGrid, GridColDef } from '@mui/x-data-grid';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { History } from '@mui/icons-material';
|
||||
import { communicationsService } from '../services/communicationsService';
|
||||
import { EmailLog } from '../types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, []);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await communicationsService.getLogs(100);
|
||||
setLogs(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: 'id', headerName: t('communications.logs.columns.id'), width: 70 },
|
||||
{
|
||||
field: 'sentDate', headerName: t('communications.logs.columns.date'), width: 180,
|
||||
valueFormatter: (params) => dayjs(params.value).format('DD/MM/YYYY HH:mm')
|
||||
},
|
||||
{
|
||||
field: 'status', headerName: t('communications.logs.columns.status'), width: 120,
|
||||
renderCell: (params) => (
|
||||
<span style={{
|
||||
color: params.value === 'Success' ? 'green' : 'red',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{params.value}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ field: 'sender', headerName: t('communications.logs.columns.sender'), width: 200 },
|
||||
{ field: 'recipient', headerName: t('communications.logs.columns.recipient'), width: 200 },
|
||||
{ field: 'subject', headerName: t('communications.logs.columns.subject'), flex: 1 },
|
||||
{ field: 'errorMessage', headerName: t('communications.logs.columns.error'), width: 200 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box p={3} sx={{ height: '80vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h4"><History /> {t('communications.logs.title')}</Typography>
|
||||
</Box>
|
||||
<DataGrid
|
||||
rows={logs}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
}}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
243
src/frontend/src/apps/communications/pages/SettingsPage.tsx
Normal file
243
src/frontend/src/apps/communications/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
Box, Paper, Typography, TextField, Button, Grid,
|
||||
Switch, FormControlLabel, Divider, Alert, Snackbar,
|
||||
FormControl, InputLabel, Select, MenuItem
|
||||
} from '@mui/material';
|
||||
import { Save, Send, Email } from '@mui/icons-material';
|
||||
import { communicationsService } from '../services/communicationsService';
|
||||
import { SmtpConfig, TestEmail } from '../types';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { control, handleSubmit, reset, watch } = useForm<SmtpConfig>();
|
||||
const provider = watch('provider') || 'smtp';
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testMode, setTestMode] = useState(false);
|
||||
const [testData, setTestData] = useState<TestEmail>({ to: '', subject: 'Test Email', body: 'Test content' });
|
||||
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const config = await communicationsService.getConfig();
|
||||
reset(config);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNotification({ type: 'error', message: t('communications.settings.messages.loadError') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: SmtpConfig) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await communicationsService.saveConfig(data);
|
||||
setNotification({ type: 'success', message: t('communications.settings.messages.saveSuccess') });
|
||||
} catch (error) {
|
||||
setNotification({ type: 'error', message: t('communications.settings.messages.saveError') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendTest = async () => {
|
||||
if (!testData.to) {
|
||||
setNotification({ type: 'error', message: t('communications.settings.messages.recipientRequired') });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await communicationsService.sendTestEmail(testData);
|
||||
setNotification({ type: 'success', message: t('communications.settings.messages.testSuccess') });
|
||||
setTestMode(false);
|
||||
} catch (error: any) {
|
||||
setNotification({ type: 'error', message: error.response?.data?.message || t('communications.settings.messages.testError') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Typography variant="h4" gutterBottom display="flex" alignItems="center" gap={2}>
|
||||
<Email fontSize="large" color="primary" /> {t('communications.settings.title')}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('communications.settings.fields.provider')}</InputLabel>
|
||||
<Controller
|
||||
name="provider"
|
||||
control={control}
|
||||
defaultValue="smtp"
|
||||
render={({ field }) => (
|
||||
<Select {...field} label={t('communications.settings.fields.provider')}>
|
||||
<MenuItem value="smtp">SMTP</MenuItem>
|
||||
<MenuItem value="resend">Resend</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{provider === 'smtp' && (
|
||||
<>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Controller
|
||||
name="host"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.host')} fullWidth required />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Controller
|
||||
name="port"
|
||||
control={control}
|
||||
defaultValue={587}
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.port')} type="number" fullWidth required />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
name="user"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.user')} fullWidth />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.password')} type="password" fullWidth />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Controller
|
||||
name="enableSsl"
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={value} onChange={onChange} />}
|
||||
label={t('communications.settings.fields.ssl')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider === 'resend' && (
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="resendApiKey"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.apiKey')} type="password" fullWidth required />}
|
||||
/>
|
||||
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{t('communications.settings.helpers.apiKey')} <a href="https://resend.com/api-keys" target="_blank" rel="noopener noreferrer">resend.com</a>
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="h6">{t('communications.settings.sections.defaultSender')}</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
name="fromEmail"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.fromEmail')} fullWidth required />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
name="fromName"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.fromName')} fullWidth />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Send />}
|
||||
onClick={() => setTestMode(!testMode)}
|
||||
>
|
||||
{t('communications.settings.actions.testConnection')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
{testMode && (
|
||||
<Paper sx={{ p: 3, bgcolor: '#f5f5f5' }}>
|
||||
<Typography variant="h6" gutterBottom>{t('communications.settings.testStats.title')}</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t('communications.settings.testStats.recipient')}
|
||||
fullWidth
|
||||
value={testData.to}
|
||||
onChange={(e) => setTestData({ ...testData, to: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t('communications.settings.testStats.subject')}
|
||||
fullWidth
|
||||
value={testData.subject}
|
||||
onChange={(e) => setTestData({ ...testData, subject: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button variant="contained" color="secondary" onClick={sendTest} disabled={loading}>
|
||||
{t('communications.settings.actions.sendTest')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={!!notification}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setNotification(null)}
|
||||
>
|
||||
<Alert severity={notification?.type || 'info'} onClose={() => setNotification(null)}>
|
||||
{notification?.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
16
src/frontend/src/apps/communications/routes.tsx
Normal file
16
src/frontend/src/apps/communications/routes.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import LogsPage from "./pages/LogsPage";
|
||||
import CommunicationsLayout from "./components/CommunicationsLayout";
|
||||
|
||||
export default function CommunicationsRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<CommunicationsLayout />}>
|
||||
<Route index element={<Navigate to="settings" replace />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import api from '../../../services/api';
|
||||
import { SmtpConfig, TestEmail, EmailLog } from '../types';
|
||||
|
||||
export const communicationsService = {
|
||||
getConfig: async () => {
|
||||
const response = await api.get<SmtpConfig>('/communications/config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
saveConfig: async (config: SmtpConfig) => {
|
||||
await api.post('/communications/config', config);
|
||||
},
|
||||
|
||||
sendTestEmail: async (data: TestEmail) => {
|
||||
await api.post('/communications/send-test', data);
|
||||
},
|
||||
|
||||
getLogs: async (limit: number = 50) => {
|
||||
const response = await api.get<EmailLog[]>('/communications/logs', { params: { limit } });
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
27
src/frontend/src/apps/communications/types/index.ts
Normal file
27
src/frontend/src/apps/communications/types/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password?: string;
|
||||
enableSsl: boolean;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
provider?: 'smtp' | 'resend';
|
||||
resendApiKey?: string;
|
||||
}
|
||||
|
||||
export interface TestEmail {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface EmailLog {
|
||||
id: number;
|
||||
sentDate: string;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
305
src/frontend/src/apps/warehouse/pages/CategoriesPage.tsx
Normal file
305
src/frontend/src/apps/warehouse/pages/CategoriesPage.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
TextField,
|
||||
Typography,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Collapse,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Folder as FolderIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
KeyboardArrowRight as KeyboardArrowRightIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useCategoryTree, useCreateCategory, useUpdateCategory, useDeleteCategory } from '../hooks';
|
||||
import { CategoryTreeDto, CreateCategoryDto, UpdateCategoryDto } from '../types';
|
||||
|
||||
interface CategoryItemProps {
|
||||
category: CategoryTreeDto;
|
||||
onEdit: (category: CategoryTreeDto) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onAddSubCategory: (parentId: number) => void;
|
||||
}
|
||||
|
||||
const CategoryItem: React.FC<CategoryItemProps> = ({ category, onEdit, onDelete, onAddSubCategory }) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const hasChildren = category.children && category.children.length > 0;
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
sx={{
|
||||
pl: category.level * 4,
|
||||
borderBottom: '1px solid #eee',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
secondaryAction={
|
||||
<Box>
|
||||
<IconButton size="small" onClick={() => onAddSubCategory(category.id)} title="Aggiungi sottocategoria">
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => onEdit(category)} title="Modifica">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(category.id)} title="Elimina" color="error">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 40, cursor: hasChildren ? 'pointer' : 'default' }} onClick={(e) => hasChildren && handleToggle(e)}>
|
||||
{hasChildren ? (open ? <ExpandMoreIcon /> : <KeyboardArrowRightIcon />) : <Box sx={{ width: 24 }} />}
|
||||
</ListItemIcon>
|
||||
<ListItemIcon>
|
||||
<FolderIcon color={category.isActive ? 'primary' : 'disabled'} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="body1" fontWeight="medium">
|
||||
{category.name}
|
||||
</Typography>
|
||||
}
|
||||
secondary={category.description}
|
||||
/>
|
||||
</ListItem>
|
||||
{hasChildren && (
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{category.children.map((child) => (
|
||||
<CategoryItem
|
||||
key={child.id}
|
||||
category={child}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddSubCategory={onAddSubCategory}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: categories, isLoading } = useCategoryTree();
|
||||
const createMutation = useCreateCategory();
|
||||
const updateMutation = useUpdateCategory();
|
||||
const deleteMutation = useDeleteCategory();
|
||||
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<CategoryTreeDto | null>(null);
|
||||
const [parentCategoryId, setParentCategoryId] = useState<number | undefined>(undefined);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<number | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<CreateCategoryDto>({
|
||||
name: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
parentCategoryId: undefined,
|
||||
});
|
||||
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
const handleOpenDialog = (category?: CategoryTreeDto, parentId?: number) => {
|
||||
if (category) {
|
||||
setEditingCategory(category);
|
||||
setFormData({
|
||||
name: category.name,
|
||||
description: category.description || '',
|
||||
sortOrder: 0, // Not in TreeDto usually, default to 0
|
||||
parentCategoryId: undefined, // Usually handled by structure
|
||||
});
|
||||
setIsActive(category.isActive);
|
||||
setParentCategoryId(undefined);
|
||||
} else {
|
||||
setEditingCategory(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
parentCategoryId: parentId,
|
||||
});
|
||||
setIsActive(true);
|
||||
setParentCategoryId(parentId);
|
||||
}
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingCategory(null);
|
||||
setParentCategoryId(undefined);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (editingCategory) {
|
||||
const updateData: UpdateCategoryDto = {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
sortOrder: formData.sortOrder,
|
||||
isActive: isActive,
|
||||
};
|
||||
await updateMutation.mutateAsync({ id: editingCategory.id, data: updateData });
|
||||
} else {
|
||||
const createData: CreateCategoryDto = {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
sortOrder: formData.sortOrder,
|
||||
parentCategoryId: parentCategoryId,
|
||||
};
|
||||
await createMutation.mutateAsync(createData);
|
||||
}
|
||||
handleCloseDialog();
|
||||
} catch (error) {
|
||||
console.error("Error saving category:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: number) => {
|
||||
setCategoryToDelete(id);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (categoryToDelete) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(categoryToDelete);
|
||||
setDeleteConfirmOpen(false);
|
||||
setCategoryToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting category:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Typography>{t('common.loading')}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ mb: 3, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{t('apps.warehouse.categories.title')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenDialog()}
|
||||
>
|
||||
{t('apps.warehouse.categories.newParams.root')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper elevation={0} variant="outlined">
|
||||
<List>
|
||||
{categories?.map((category) => (
|
||||
<CategoryItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
onEdit={handleOpenDialog}
|
||||
onDelete={handleDeleteClick}
|
||||
onAddSubCategory={(parentId) => handleOpenDialog(undefined, parentId)}
|
||||
/>
|
||||
))}
|
||||
{(!categories || categories.length === 0) && (
|
||||
<ListItem>
|
||||
<ListItemText primary={t('apps.warehouse.categories.empty')} />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingCategory ? t('apps.warehouse.categories.edit') : t('apps.warehouse.categories.new')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label={t('apps.warehouse.categories.fields.name')}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label={t('apps.warehouse.categories.fields.description')}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
<TextField
|
||||
label={t('apps.warehouse.categories.fields.sortOrder')}
|
||||
type="number"
|
||||
value={formData.sortOrder}
|
||||
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
fullWidth
|
||||
/>
|
||||
{editingCategory && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('apps.warehouse.categories.fields.active')}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} variant="contained" disabled={!formData.name}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteConfirmOpen} onClose={() => setDeleteConfirmOpen(false)}>
|
||||
<DialogTitle>{t('apps.warehouse.categories.deleteDialog.title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
{t('apps.warehouse.categories.deleteDialog.content')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteConfirmOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleConfirmDelete} color="error" variant="contained">
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export { default as WarehouseDashboard } from './WarehouseDashboard';
|
||||
// Articles
|
||||
export { default as ArticlesPage } from './ArticlesPage';
|
||||
export { default as ArticleFormPage } from './ArticleFormPage';
|
||||
export { default as CategoriesPage } from './CategoriesPage';
|
||||
|
||||
// Warehouse Locations
|
||||
export { default as WarehouseLocationsPage } from './WarehouseLocationsPage';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
WarehouseDashboard,
|
||||
ArticlesPage,
|
||||
ArticleFormPage,
|
||||
CategoriesPage,
|
||||
WarehouseLocationsPage,
|
||||
MovementsPage,
|
||||
InboundMovementPage,
|
||||
@@ -30,6 +31,7 @@ export default function WarehouseRoutes() {
|
||||
<Route path="articles/new" element={<ArticleFormPage />} />
|
||||
<Route path="articles/:id" element={<ArticleFormPage />} />
|
||||
<Route path="articles/:id/edit" element={<ArticleFormPage />} />
|
||||
<Route path="categories" element={<CategoriesPage />} />
|
||||
|
||||
{/* Warehouse Locations */}
|
||||
<Route path="locations" element={<WarehouseLocationsPage />} />
|
||||
|
||||
@@ -558,6 +558,7 @@ export const inventoryService = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Export all services
|
||||
export default {
|
||||
locations: warehouseLocationService,
|
||||
|
||||
@@ -230,6 +230,7 @@ export interface UpdateCategoryDto {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
|
||||
// ===============================================
|
||||
// ARTICLE
|
||||
// ===============================================
|
||||
@@ -252,6 +253,7 @@ export interface ArticleDto {
|
||||
isSerialManaged: boolean;
|
||||
hasExpiry: boolean;
|
||||
expiryWarningDays?: number;
|
||||
giorniValidita?: number;
|
||||
minimumStock?: number;
|
||||
maximumStock?: number;
|
||||
reorderPoint?: number;
|
||||
@@ -287,6 +289,7 @@ export interface CreateArticleDto {
|
||||
isSerialManaged: boolean;
|
||||
hasExpiry: boolean;
|
||||
expiryWarningDays?: number;
|
||||
giorniValidita?: number;
|
||||
minimumStock?: number;
|
||||
maximumStock?: number;
|
||||
reorderPoint?: number;
|
||||
@@ -316,6 +319,7 @@ export interface UpdateArticleDto {
|
||||
isSerialManaged: boolean;
|
||||
hasExpiry: boolean;
|
||||
expiryWarningDays?: number;
|
||||
giorniValidita?: number;
|
||||
minimumStock?: number;
|
||||
maximumStock?: number;
|
||||
reorderPoint?: number;
|
||||
|
||||
@@ -64,14 +64,14 @@ export default function SearchBar() {
|
||||
const options = useMemo(() => {
|
||||
const opts: SearchOption[] = [
|
||||
// Core
|
||||
{ label: t('menu.dashboard'), path: '/', category: 'Zentral', translationKey: 'menu.dashboard' },
|
||||
{ label: t('menu.calendar'), path: '/calendario', category: 'Zentral', translationKey: 'menu.calendar' },
|
||||
{ label: t('menu.events'), path: '/eventi', category: 'Zentral', translationKey: 'menu.events' },
|
||||
{ label: t('menu.clients'), path: '/clienti', category: 'Zentral', translationKey: 'menu.clients' },
|
||||
{ label: t('menu.location'), path: '/location', category: 'Zentral', translationKey: 'menu.location' },
|
||||
{ label: t('menu.articles'), path: '/articoli', category: 'Zentral', translationKey: 'menu.articles' },
|
||||
{ label: t('menu.resources'), path: '/risorse', category: 'Zentral', translationKey: 'menu.resources' },
|
||||
{ label: t('menu.reports'), path: '/report-templates', category: 'Zentral', translationKey: 'menu.reports' },
|
||||
{ label: t('menu.dashboard'), path: '/', category: t('apps.core.title'), translationKey: 'menu.dashboard' },
|
||||
{ label: t('menu.calendar'), path: '/calendario', category: t('apps.core.title'), translationKey: 'menu.calendar' },
|
||||
{ label: t('menu.events'), path: '/eventi', category: t('apps.core.title'), translationKey: 'menu.events' },
|
||||
{ label: t('menu.clients'), path: '/clienti', category: t('apps.core.title'), translationKey: 'menu.clients' },
|
||||
{ label: t('menu.location'), path: '/location', category: t('apps.core.title'), translationKey: 'menu.location' },
|
||||
{ label: t('menu.articles'), path: '/articoli', category: t('apps.core.title'), translationKey: 'menu.articles' },
|
||||
{ label: t('menu.resources'), path: '/risorse', category: t('apps.core.title'), translationKey: 'menu.resources' },
|
||||
{ label: t('menu.reports'), path: '/report-templates', category: t('apps.core.title'), translationKey: 'menu.reports' },
|
||||
];
|
||||
|
||||
if (activeAppCodes.includes('warehouse')) {
|
||||
@@ -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' },
|
||||
|
||||
@@ -36,10 +36,13 @@ import {
|
||||
Timeline as TimelineIcon,
|
||||
PrecisionManufacturing as ManufacturingIcon,
|
||||
Category as CategoryIcon,
|
||||
Folder as FolderIcon,
|
||||
AttachMoney as AttachMoneyIcon,
|
||||
Receipt as ReceiptIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Email as EmailIcon,
|
||||
School as SchoolIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -76,6 +79,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
|
||||
production: false,
|
||||
events: false,
|
||||
hr: false,
|
||||
training: false,
|
||||
admin: false,
|
||||
});
|
||||
|
||||
@@ -101,7 +105,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
|
||||
const menuStructure: MenuItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Zentral Dashboard',
|
||||
label: t('menu.dashboard'),
|
||||
icon: <DashboardIcon />,
|
||||
path: '/',
|
||||
translationKey: 'menu.dashboard',
|
||||
@@ -115,6 +119,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
|
||||
children: [
|
||||
{ id: 'wh-dashboard', label: t('menu.dashboard'), tabLabel: t('menu.warehouse'), icon: <DashboardIcon />, path: '/warehouse', translationKey: 'menu.warehouse' },
|
||||
{ id: 'wh-articles', label: t('menu.articles'), icon: <CategoryIcon />, path: '/warehouse/articles', translationKey: 'menu.articles' },
|
||||
{ id: 'wh-categories', label: t('menu.categories'), icon: <FolderIcon />, path: '/warehouse/categories', translationKey: 'menu.categories' },
|
||||
{ id: 'wh-locations', label: t('menu.location'), icon: <PlaceIcon />, path: '/warehouse/locations', translationKey: 'menu.location' },
|
||||
{ id: 'wh-movements', label: t('menu.movements'), icon: <SwapIcon />, path: '/warehouse/movements', translationKey: 'menu.movements' },
|
||||
{ id: 'wh-stock', label: t('menu.stock'), icon: <StorageIcon />, path: '/warehouse/stock', translationKey: 'menu.stock' },
|
||||
@@ -183,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'),
|
||||
@@ -193,6 +214,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
|
||||
{ id: 'autocodes', label: t('menu.autoCodes'), icon: <AutoCodeIcon />, path: '/admin/auto-codes', translationKey: 'menu.autoCodes' },
|
||||
{ id: 'customfields', label: t('menu.customFields'), icon: <AutoCodeIcon />, path: '/admin/custom-fields', translationKey: 'menu.customFields' },
|
||||
{ id: 'reports', label: t('menu.reports'), icon: <PrintIcon />, path: '/report-designer', appCode: 'report-designer', translationKey: 'menu.reports' },
|
||||
{ id: 'email-config', label: t('menu.emailConfig'), icon: <EmailIcon />, path: '/admin/email-config', translationKey: 'menu.emailConfig' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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