Compare commits

..

7 Commits

51 changed files with 22213 additions and 268 deletions

View 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`).

View File

@@ -2,6 +2,8 @@
trigger: always_on 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. 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. 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.

View File

@@ -20,7 +20,7 @@ File riassuntivo dello stato di sviluppo di Zentral.
- Implementazione modulo Gestione Eventi: strutturazione frontend, integrazione funzionalità e attivazione store. - Implementazione modulo Gestione Eventi: strutturazione frontend, integrazione funzionalità e attivazione store.
- [Event Module Development](./devlog/event-module.md) - Implementazione modulo eventi - [Event Module Development](./devlog/event-module.md) - Implementazione modulo eventi
- [Menu Refactoring](./devlog/menu-refactoring.md) - Riorganizzazione menu e moduli (Dashboard, Clienti, Articoli, Risorse) - [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). - 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** - [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. - Pulizia menu Zentral (rimozione voci ridondanti) e creazione nuova Dashboard principale con riepilogo moduli attivi.
@@ -48,8 +48,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** - [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. - Corretta chiave di traduzione errata per la tab "Gestione Applicazioni" e migliorata la gestione dell'aggiornamento etichette tab.
- [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato** - [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato**
- 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**
- [2025-12-12 - Modulo Comunicazioni](./devlog/2025-12-12-110000_communications_module.md) - **In Corso** - Implementazione gestione Corsi (sottocategorie Formazione), Registro Training, Scadenze, Notifiche e Dashboard.
- Implementazione invio email e gestione comunicazioni. - [2025-12-12 Communications Module](./devlog/2025-12-12-110000_communications_module.md) - **Completato**
- [2025-12-12 - Gestione Modulo Formazione (Generale)](./devlog/2025-12-12-105500_safety_training_schedule.md) - **In Corso** - [2025-12-12 Resend Integration](./devlog/2025-12-12-120000_resend_integration.md) - **Completato**
- Implementazione modulo formazione generale e scadenziario. - [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.

View File

@@ -6,8 +6,10 @@ Creare un modulo generale per la gestione della formazione (Training), permetten
## Strategia ## Strategia
Mapping delle funzionalità sui moduli esistenti: Mapping delle funzionalità sui moduli esistenti:
1. **Anagrafica Corsi** -> Modulo **Magazzino** (`Articolo`) 1. **Anagrafica Corsi** -> Modulo **Magazzino** (`Articolo`)
- La radice della Categoria Merceologica sarà "Formazione". - Viene introdotta una **Classificazione Specifica** tramite property `Tipo` (`Standard`, `Corso`, `Servizio`).
- Le sottocategorie definiranno il tipo di corso (es. "Sicurezza", "IT"). - 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`) 2. **Anagrafica Soggetti** -> Modulo **Clienti** (`Cliente` + nuova entità `ClienteContatto`)
3. **Gestione Attestati e Scadenze** -> Nuovo Modulo **Training** (Formazione) 3. **Gestione Attestati e Scadenze** -> Nuovo Modulo **Training** (Formazione)
4. **Workflow Notifiche** -> Human-in-the-loop tramite Dashboard dedicato. 4. **Workflow Notifiche** -> Human-in-the-loop tramite Dashboard dedicato.
@@ -15,34 +17,34 @@ Mapping delle funzionalità sui moduli esistenti:
## Piano di Lavoro ## Piano di Lavoro
### 1. Documentazione e Analisi ### 1. Documentazione e Analisi
- [ ] Creazione piano di lavoro (questo file). - [x] Creazione piano di lavoro (questo file).
- [ ] Aggiornamento `ZENTRAL.md`. - [x] Aggiornamento `ZENTRAL.md`.
### 2. Backend (.NET) ### 2. Backend (.NET)
#### Domain Layer #### Domain Layer
- [ ] **Refactoring Categorie (Warehouse)**: - [x] **Refactoring Categorie (Warehouse)**:
- Implementare gestione **Gruppi Merceologici a 3 livelli** (Standardizzazione Classificazione). - Implementare gestione **Gruppi Merceologici a 3 livelli** (Standardizzazione Classificazione).
- Utilizzare la categoria "Formazione" come root per identificare i corsi. - Utilizzare la categoria "Formazione" come root per identificare i corsi.
- [ ] **Modifica Entity `Articolo`**: - [x] **Modifica Entity `Articolo`**:
- Aggiungere gestione **Validità/Scadenza Standard** (es. `int? GiorniValidita`). - Aggiungere gestione **Validità/Scadenza Standard** (es. `int? GiorniValidita`).
- Il campo sarà utilizzato per calcolare la data di scadenza del corso una volta erogato. - Il campo sarà utilizzato per calcolare la data di scadenza del corso una volta erogato.
- [ ] **Nuova Entity `ClienteContatto`**: - [x] **Nuova Entity `ClienteContatto`**:
- Proprietà: `Nome`, `Cognome`, `Email`, `Ruolo`, `Telefono`, foreign key a `Cliente`. - Proprietà: `Nome`, `Cognome`, `Email`, `Ruolo`, `Telefono`, foreign key a `Cliente`.
- Aggiornare `Cliente` con collection `Contatti`. - Aggiornare `Cliente` con collection `Contatti`.
- [ ] **Nuova Entity `TrainingRecord`**: - [x] **Nuova Entity `TrainingRecord`**:
- Rappresenta l'avvenuta formazione per un contatto. - Rappresenta l'avvenuta formazione per un contatto.
- Proprietà: `ClienteContattoId`, `ArticoloId` (Corso), `DataEsecuzione`, `DataScadenza` (Calcolata), `AttestatoUrl`, `Stato` (Valid, Expiring, Expired), `Note`. - Proprietà: `ClienteContattoId`, `ArticoloId` (Corso), `DataEsecuzione`, `DataScadenza` (Calcolata), `AttestatoUrl`, `Stato` (Valid, Expiring, Expired), `Note`.
- Entità generica per qualsiasi tipo di corso. - Entità generica per qualsiasi tipo di corso.
#### Infrastructure / EF Core #### Infrastructure / EF Core
- [ ] Creare Migrazione EF per le nuove entità e modifiche. - [x] Creare Migrazione EF per le nuove entità e modifiche.
- [ ] Aggiornare `ApplicationDbContext`. - [x] Aggiornare `ApplicationDbContext`.
#### API Layer #### API Layer
- [ ] **Aggiornare `ArticoliController`**: Gestione nuovi campi (Validità, Categorie). - [x] **Aggiornare `ArticoliController`**: Gestione nuovi campi (Validità, Categorie).
- [ ] **Gestione Classificazioni**: Implementare API per gestire la gerarchia (o livelli) delle categorie merceologiche. - [x] **Gestione Classificazioni**: Implementare API per gestire la gerarchia (o livelli) delle categorie merceologiche.
- [ ] **Aggiornare `ClientiController`**: Gestione CRUD Contatti. - [x] **Aggiornare `ClientiController`**: Gestione CRUD Contatti.
- [ ] **Nuovo `TrainingController`**: - [x] **Nuovo `TrainingController`**:
- CRUD TrainingRecords. - CRUD TrainingRecords.
- Upload file attestato. - Upload file attestato.
- Endpoint `GetExpiringTrainings` per la dashboard (filtri per data, azienda, categoria corso). - Endpoint `GetExpiringTrainings` per la dashboard (filtri per data, azienda, categoria corso).
@@ -50,21 +52,22 @@ Mapping delle funzionalità sui moduli esistenti:
### 3. Frontend (React) ### 3. Frontend (React)
#### Modulo Training (Nuova App `training`) #### Modulo Training (Nuova App `training`)
- [ ] **Setup Modulo**: Creare cartella `src/frontend/src/apps/training` e configurare route. - [x] **Setup Modulo**: Creare cartella `src/frontend/src/apps/training` e configurare route.
- [ ] **Componenti**: - [x] **Componenti**:
- `TrainingDashboard`: Widget con scadenze imminenti e scadute, grafici per tipologia corso. - `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). - `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. - `TrainingMatrix`: Vista partecipanti x corsi o lista formazioni.
- `TrainingForm`: Modale inserimento/modifica formazione (Caricamento file, calcolo automatico scadenza basato sul corso). - `TrainingForm`: Modale inserimento/modifica formazione (Caricamento file, calcolo automatico scadenza basato sul corso).
#### Integrazione Moduli Esistenti #### Integrazione Moduli Esistenti
- [ ] **Magazzino**: Gestione UI per Classificazioni a 3 livelli (Gruppo/Famiglia). - [x] **Magazzino**: Gestione UI per Classificazioni a 3 livelli (Gruppo/Famiglia). (Implementato selezione sottocategorie in RegistryPage)
- [ ] **Magazzino**: Aggiungere campi Validità/Scadenza nel form Articolo. - [x] **Magazzino**: Aggiungere campi Validità/Scadenza nel form Articolo.
- [ ] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti. - [x] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti.
- [x] **UI**: Aggiungere "Training" a `Sidebar.tsx` e `SearchBar.tsx`.
### 4. Workflow e Notifiche ### 4. Workflow e Notifiche
- [ ] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard. - [x] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard. (Aggiunto pulsante invio notifica)
- [ ] Integrazione con il Modulo Email per invio solleciti scadenze. - [x] Integrazione con il Modulo Email per invio solleciti scadenze.
### 5. Verifica e Test ### 5. Verifica e Test
- [ ] Test flusso completo: - [ ] Test flusso completo:
@@ -75,4 +78,8 @@ Mapping delle funzionalità sui moduli esistenti:
5. Verifica Scadenza e Notifica. 5. Verifica Scadenza e Notifica.
## Stato Attuale ## Stato Attuale
- Inizio analisi e setup. - Implementazione Core (Backend/Frontend) completata.
- Integrazione Modulo Comunicazioni completata (Controllo attivazione app + invio email).
- 2025-12-12-174800_rimosse_tab_interne_modulo_formazione: Rimosse le tab interne (Dashboard, Registry, Matrix) dal layout del modulo Formazione in quanto ridondanti rispetto alla navigazione principale.
- 2025-12-12-185000_integrazione_comunicazioni_formazione: Implementata integrazione formale con modulo Comunicazioni (Check AppService + logging).
- 2025-12-12-190500_fix_seed_db: Risolto bug mancata creazione categoria "Formazione" (TRAIN) nel seed del database per database esistenti.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -28,6 +28,10 @@ public interface IWarehouseService
Task<WarehouseArticleCategory> UpdateCategoryAsync(WarehouseArticleCategory category); Task<WarehouseArticleCategory> UpdateCategoryAsync(WarehouseArticleCategory category);
Task DeleteCategoryAsync(int id); Task DeleteCategoryAsync(int id);
// ===============================================
// GRUPPI MERCEOLOGICI
// ===============================================
// =============================================== // ===============================================
// MAGAZZINI // MAGAZZINI
// =============================================== // ===============================================

View File

@@ -60,6 +60,7 @@ public class WarehouseService : IWarehouseService
if (filter.CategoryId.HasValue) if (filter.CategoryId.HasValue)
query = query.Where(a => a.CategoryId == filter.CategoryId); query = query.Where(a => a.CategoryId == filter.CategoryId);
if (filter.IsActive.HasValue) if (filter.IsActive.HasValue)
query = query.Where(a => a.IsActive == filter.IsActive); query = query.Where(a => a.IsActive == filter.IsActive);
@@ -336,6 +337,7 @@ public class WarehouseService : IWarehouseService
#endregion #endregion
#region Magazzini #region Magazzini
public async Task<List<WarehouseLocation>> GetWarehousesAsync(bool includeInactive = false) public async Task<List<WarehouseLocation>> GetWarehousesAsync(bool includeInactive = false)

View File

@@ -24,7 +24,8 @@ public class ArticoliController : ControllerBase
[FromQuery] string? search, [FromQuery] string? search,
[FromQuery] int? tipoMaterialeId, [FromQuery] int? tipoMaterialeId,
[FromQuery] int? categoriaId, [FromQuery] int? categoriaId,
[FromQuery] bool? attivo) [FromQuery] bool? attivo,
[FromQuery] TipoArticolo? tipo)
{ {
var query = _context.Articoli var query = _context.Articoli
.Include(a => a.TipoMateriale) .Include(a => a.TipoMateriale)
@@ -43,6 +44,9 @@ public class ArticoliController : ControllerBase
if (attivo.HasValue) if (attivo.HasValue)
query = query.Where(a => a.Attivo == attivo.Value); 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(); return await query.OrderBy(a => a.Descrizione).ToListAsync();
} }

View File

@@ -39,6 +39,7 @@ public class ClientiController : ControllerBase
{ {
var cliente = await _context.Clienti var cliente = await _context.Clienti
.Include(c => c.Eventi) .Include(c => c.Eventi)
.Include(c => c.Contatti)
.FirstOrDefaultAsync(c => c.Id == id); .FirstOrDefaultAsync(c => c.Id == id);
if (cliente == null) if (cliente == null)
@@ -99,4 +100,53 @@ public class ClientiController : ControllerBase
return NoContent(); 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();
}
} }

View File

@@ -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" });
}
}

View File

@@ -24,8 +24,21 @@ public class Articolo : BaseEntity
public string? MimeType { get; set; } public string? MimeType { get; set; }
public string? Note { get; set; } public string? Note { get; set; }
public bool Attivo { get; set; } = true; 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 TipoMateriale? TipoMateriale { get; set; }
public CodiceCategoria? Categoria { get; set; } public CodiceCategoria? Categoria { get; set; }
public ICollection<EventoDettaglioPrelievo> DettagliPrelievo { get; set; } = new List<EventoDettaglioPrelievo>(); public ICollection<EventoDettaglioPrelievo> DettagliPrelievo { get; set; } = new List<EventoDettaglioPrelievo>();
} }
public enum TipoArticolo
{
Standard = 0,
Corso = 1,
Servizio = 2
}

View File

@@ -28,4 +28,5 @@ public class Cliente : BaseEntity
public ICollection<Evento> Eventi { get; set; } = new List<Evento>(); 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<Zentral.Domain.Entities.Sales.SalesOrder> SalesOrders { get; set; } = new List<Zentral.Domain.Entities.Sales.SalesOrder>();
public ICollection<ClienteContatto> Contatti { get; set; } = new List<ClienteContatto>();
} }

View 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!;
}

View File

@@ -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; // Or unknown? Assuming valid if no expiration.
var days = (DataScadenza.Value - DateTime.Today).TotalDays;
if (days < 0) return TrainingStatus.Expired;
if (days <= 30) return TrainingStatus.Expiring;
return TrainingStatus.Valid;
}
}
}

View File

@@ -40,6 +40,11 @@ public class WarehouseArticle : BaseEntity
/// </summary> /// </summary>
public int? CategoryId { get; set; } public int? CategoryId { get; set; }
/// <summary>
/// Gruppo merceologico
/// </summary>
public int? ProductGroupId { get; set; }
/// <summary> /// <summary>
/// Unità di misura principale (es. PZ, KG, LT, MT) /// Unità di misura principale (es. PZ, KG, LT, MT)
/// </summary> /// </summary>

View File

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

View File

@@ -5,6 +5,7 @@ using Zentral.Domain.Entities.Sales;
using Zentral.Domain.Entities.Production; using Zentral.Domain.Entities.Production;
using Zentral.Domain.Entities.HR; using Zentral.Domain.Entities.HR;
using Zentral.Domain.Entities.Communications; using Zentral.Domain.Entities.Communications;
using Zentral.Domain.Entities.Training;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Zentral.Infrastructure.Data; namespace Zentral.Infrastructure.Data;
@@ -99,6 +100,10 @@ public class ZentralDbContext : DbContext
// Communications module entities // Communications module entities
public DbSet<EmailLog> EmailLogs => Set<EmailLog>(); public DbSet<EmailLog> EmailLogs => Set<EmailLog>();
// Training module entities
public DbSet<ClienteContatto> Contatti => Set<ClienteContatto>();
public DbSet<TrainingRecord> TrainingRecords => Set<TrainingRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -393,6 +398,35 @@ public class ZentralDbContext : DbContext
entity.HasIndex(e => e.EntityName); 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 // WAREHOUSE MODULE ENTITIES
// =============================================== // ===============================================
@@ -445,6 +479,7 @@ public class ZentralDbContext : DbContext
.WithMany(c => c.Articles) .WithMany(c => c.Articles)
.HasForeignKey(e => e.CategoryId) .HasForeignKey(e => e.CategoryId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
}); });
// ArticleBatch // ArticleBatch

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -174,6 +174,9 @@ namespace Zentral.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("GiorniValidita")
.HasColumnType("INTEGER");
b.Property<byte[]>("Immagine") b.Property<byte[]>("Immagine")
.HasColumnType("BLOB"); .HasColumnType("BLOB");
@@ -195,6 +198,9 @@ namespace Zentral.Infrastructure.Migrations
b.Property<decimal?>("QtaStdS") b.Property<decimal?>("QtaStdS")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("Tipo")
.HasColumnType("INTEGER");
b.Property<int?>("TipoMaterialeId") b.Property<int?>("TipoMaterialeId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -372,6 +378,54 @@ namespace Zentral.Infrastructure.Migrations
b.ToTable("Clienti"); 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 => modelBuilder.Entity("Zentral.Domain.Entities.CodiceCategoria", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -2595,6 +2649,54 @@ namespace Zentral.Infrastructure.Migrations
b.ToTable("TipiRisorsa"); b.ToTable("TipiRisorsa");
}); });
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 => modelBuilder.Entity("Zentral.Domain.Entities.UserDashboardPreference", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -3675,6 +3777,9 @@ namespace Zentral.Infrastructure.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("ProductGroupId")
.HasColumnType("INTEGER");
b.Property<decimal?>("ReorderPoint") b.Property<decimal?>("ReorderPoint")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -3921,6 +4026,17 @@ namespace Zentral.Infrastructure.Migrations
b.Navigation("TipoMateriale"); 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 => modelBuilder.Entity("Zentral.Domain.Entities.Evento", b =>
{ {
b.HasOne("Zentral.Domain.Entities.Cliente", "Cliente") b.HasOne("Zentral.Domain.Entities.Cliente", "Cliente")
@@ -4303,6 +4419,25 @@ namespace Zentral.Infrastructure.Migrations
b.Navigation("TipoPasto"); b.Navigation("TipoPasto");
}); });
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 => modelBuilder.Entity("Zentral.Domain.Entities.UserDashboardPreference", b =>
{ {
b.HasOne("Zentral.Domain.Entities.Utente", "Utente") b.HasOne("Zentral.Domain.Entities.Utente", "Utente")
@@ -4585,6 +4720,8 @@ namespace Zentral.Infrastructure.Migrations
modelBuilder.Entity("Zentral.Domain.Entities.Cliente", b => modelBuilder.Entity("Zentral.Domain.Entities.Cliente", b =>
{ {
b.Navigation("Contatti");
b.Navigation("Eventi"); b.Navigation("Eventi");
b.Navigation("SalesOrders"); b.Navigation("SalesOrders");

View File

@@ -65,7 +65,8 @@
"emailConfig": "Email Configuration", "emailConfig": "Email Configuration",
"movements": "Movements", "movements": "Movements",
"stock": "Stock", "stock": "Stock",
"inventory": "Inventory" "inventory": "Inventory",
"categories": "Categories"
}, },
"navigation": { "navigation": {
"searchPlaceholder": "Search...", "searchPlaceholder": "Search...",
@@ -285,12 +286,33 @@
"confermato": "Confirmed" "confermato": "Confirmed"
}, },
"apps": { "apps": {
"core": {
"title": "Zentral"
},
"warehouse": { "warehouse": {
"title": "Warehouse Management", "title": "Warehouse Management",
"inventory": "Inventory", "inventory": "Inventory",
"movements": "Movements", "movements": "Movements",
"stock": "Stock", "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": { "hr": {
"title": "Human Resources", "title": "Human Resources",
@@ -300,6 +322,12 @@
"pagamenti": "Payments", "pagamenti": "Payments",
"rimborsi": "Reimbursements" "rimborsi": "Reimbursements"
}, },
"training": {
"title": "Training Management",
"dashboard": "Dashboard",
"matrix": "Matrix",
"registry": "Course Registry"
},
"admin": { "admin": {
"title": "App Management", "title": "App Management",
"subtitle": "Configure active apps and manage subscriptions", "subtitle": "Configure active apps and manage subscriptions",
@@ -400,6 +428,14 @@
"4": "Expense reports and reimbursements", "4": "Expense reports and reimbursements",
"5": "Personnel cost analysis" "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" "default": "Complete app features"
} }
}, },
@@ -1552,5 +1588,56 @@
"permesso": "Permit", "permesso": "Permit",
"altro": "Other" "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"
}
}
} }
} }

View File

@@ -30,7 +30,8 @@
"preview": "Anteprima", "preview": "Anteprima",
"none": "Nessuno", "none": "Nessuno",
"view": "Dettaglio", "view": "Dettaglio",
"copy": "Copia" "copy": "Copia",
"category": "Categoria"
}, },
"menu": { "menu": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -61,7 +62,9 @@
"emailConfig": "Configurazione Email", "emailConfig": "Configurazione Email",
"movements": "Movimenti", "movements": "Movimenti",
"stock": "Giacenze", "stock": "Giacenze",
"inventory": "Inventario" "inventory": "Inventario",
"categories": "Categorie",
"training": "Formazione"
}, },
"navigation": { "navigation": {
"searchPlaceholder": "Cerca...", "searchPlaceholder": "Cerca...",
@@ -208,6 +211,10 @@
"pec": "PEC", "pec": "PEC",
"fiscalCode": "Codice Fiscale", "fiscalCode": "Codice Fiscale",
"recipientCode": "Codice Destinatario", "recipientCode": "Codice Destinatario",
"contacts": "Contatti",
"newContact": "Nuovo Contatto",
"editContact": "Modifica Contatto",
"role": "Ruolo",
"generatedOnSave": "(Generato al salvataggio)", "generatedOnSave": "(Generato al salvataggio)",
"autoGenerated": "Generato automaticamente", "autoGenerated": "Generato automaticamente",
"willBeAssigned": "Verrà assegnato automaticamente", "willBeAssigned": "Verrà assegnato automaticamente",
@@ -281,12 +288,33 @@
"confermato": "Confermato" "confermato": "Confermato"
}, },
"apps": { "apps": {
"core": {
"title": "Zentral"
},
"warehouse": { "warehouse": {
"title": "Gestione Magazzino", "title": "Gestione Magazzino",
"inventory": "Inventario", "inventory": "Inventario",
"movements": "Movimenti", "movements": "Movimenti",
"stock": "Giacenze", "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": { "hr": {
"title": "Gestione Personale", "title": "Gestione Personale",
@@ -296,6 +324,12 @@
"pagamenti": "Pagamenti", "pagamenti": "Pagamenti",
"rimborsi": "Rimborsi" "rimborsi": "Rimborsi"
}, },
"training": {
"title": "Gestione Formazione",
"dashboard": "Dashboard",
"matrix": "Matrice",
"registry": "Anagrafica Corsi"
},
"admin": { "admin": {
"title": "Gestione Applicazioni", "title": "Gestione Applicazioni",
"subtitle": "Configura le applicazioni attive e gestisci le subscription", "subtitle": "Configura le applicazioni attive e gestisci le subscription",
@@ -397,6 +431,14 @@
"4": "Note spese e rimborsi", "4": "Note spese e rimborsi",
"5": "Analisi costi personale" "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" "default": "Funzionalità complete dell'applicazione"
} }
}, },
@@ -1633,5 +1675,83 @@
"permesso": "Permesso", "permesso": "Permesso",
"altro": "Altro" "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"
} }
} }

View File

@@ -20,6 +20,7 @@ import ProductionRoutes from "./apps/production/routes";
import EventsRoutes from "./apps/events/routes"; import EventsRoutes from "./apps/events/routes";
import HRRoutes from "./apps/hr/routes"; import HRRoutes from "./apps/hr/routes";
import CommunicationsRoutes from "./apps/communications/routes"; import CommunicationsRoutes from "./apps/communications/routes";
import TrainingRoutes from "./apps/training/routes";
import { AppGuard } from "./components/AppGuard"; import { AppGuard } from "./components/AppGuard";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates"; import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext"; import { CollaborationProvider } from "./contexts/CollaborationContext";
@@ -150,6 +151,15 @@ function App() {
</AppGuard> </AppGuard>
} }
/> />
{/* Training Module */}
<Route
path="training/*"
element={
<AppGuard appCode="training">
<TrainingRoutes />
</AppGuard>
}
/>
</Route> </Route>
</Routes> </Routes>
</TabProvider> </TabProvider>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DataGrid, GridColDef } from '@mui/x-data-grid'; import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Box, Typography } from '@mui/material'; import { Box, Typography } from '@mui/material';
import { History } from '@mui/icons-material'; import { History } from '@mui/icons-material';
@@ -7,6 +8,7 @@ import { EmailLog } from '../types';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
export default function LogsPage() { export default function LogsPage() {
const { t } = useTranslation();
const [logs, setLogs] = useState<EmailLog[]>([]); const [logs, setLogs] = useState<EmailLog[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -27,13 +29,13 @@ export default function LogsPage() {
}; };
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ field: 'id', headerName: 'ID', width: 70 }, { field: 'id', headerName: t('communications.logs.columns.id'), width: 70 },
{ {
field: 'sentDate', headerName: 'Data', width: 180, field: 'sentDate', headerName: t('communications.logs.columns.date'), width: 180,
valueFormatter: (params) => dayjs(params.value).format('DD/MM/YYYY HH:mm') valueFormatter: (params) => dayjs(params.value).format('DD/MM/YYYY HH:mm')
}, },
{ {
field: 'status', headerName: 'Stato', width: 120, field: 'status', headerName: t('communications.logs.columns.status'), width: 120,
renderCell: (params) => ( renderCell: (params) => (
<span style={{ <span style={{
color: params.value === 'Success' ? 'green' : 'red', color: params.value === 'Success' ? 'green' : 'red',
@@ -43,16 +45,16 @@ export default function LogsPage() {
</span> </span>
) )
}, },
{ field: 'sender', headerName: 'Mittente', width: 200 }, { field: 'sender', headerName: t('communications.logs.columns.sender'), width: 200 },
{ field: 'recipient', headerName: 'Destinatario', width: 200 }, { field: 'recipient', headerName: t('communications.logs.columns.recipient'), width: 200 },
{ field: 'subject', headerName: 'Oggetto', flex: 1 }, { field: 'subject', headerName: t('communications.logs.columns.subject'), flex: 1 },
{ field: 'errorMessage', headerName: 'Errore', width: 200 }, { field: 'errorMessage', headerName: t('communications.logs.columns.error'), width: 200 },
]; ];
return ( return (
<Box p={3} sx={{ height: '80vh', display: 'flex', flexDirection: 'column' }}> <Box p={3} sx={{ height: '80vh', display: 'flex', flexDirection: 'column' }}>
<Box display="flex" justifyContent="space-between" mb={2}> <Box display="flex" justifyContent="space-between" mb={2}>
<Typography variant="h4"><History /> Email Logs</Typography> <Typography variant="h4"><History /> {t('communications.logs.title')}</Typography>
</Box> </Box>
<DataGrid <DataGrid
rows={logs} rows={logs}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { import {
Box, Paper, Typography, TextField, Button, Grid, Box, Paper, Typography, TextField, Button, Grid,
@@ -10,6 +11,7 @@ import { communicationsService } from '../services/communicationsService';
import { SmtpConfig, TestEmail } from '../types'; import { SmtpConfig, TestEmail } from '../types';
export default function SettingsPage() { export default function SettingsPage() {
const { t } = useTranslation();
const { control, handleSubmit, reset, watch } = useForm<SmtpConfig>(); const { control, handleSubmit, reset, watch } = useForm<SmtpConfig>();
const provider = watch('provider') || 'smtp'; const provider = watch('provider') || 'smtp';
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -28,7 +30,7 @@ export default function SettingsPage() {
reset(config); reset(config);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setNotification({ type: 'error', message: 'Failed to load configuration' }); setNotification({ type: 'error', message: t('communications.settings.messages.loadError') });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -38,9 +40,9 @@ export default function SettingsPage() {
try { try {
setLoading(true); setLoading(true);
await communicationsService.saveConfig(data); await communicationsService.saveConfig(data);
setNotification({ type: 'success', message: 'Configuration saved successfully' }); setNotification({ type: 'success', message: t('communications.settings.messages.saveSuccess') });
} catch (error) { } catch (error) {
setNotification({ type: 'error', message: 'Failed to save configuration' }); setNotification({ type: 'error', message: t('communications.settings.messages.saveError') });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -48,16 +50,16 @@ export default function SettingsPage() {
const sendTest = async () => { const sendTest = async () => {
if (!testData.to) { if (!testData.to) {
setNotification({ type: 'error', message: 'Recipient email is required for test' }); setNotification({ type: 'error', message: t('communications.settings.messages.recipientRequired') });
return; return;
} }
try { try {
setLoading(true); setLoading(true);
await communicationsService.sendTestEmail(testData); await communicationsService.sendTestEmail(testData);
setNotification({ type: 'success', message: 'Test email queued successfully' }); setNotification({ type: 'success', message: t('communications.settings.messages.testSuccess') });
setTestMode(false); setTestMode(false);
} catch (error: any) { } catch (error: any) {
setNotification({ type: 'error', message: error.response?.data?.message || 'Failed to send test email' }); setNotification({ type: 'error', message: error.response?.data?.message || t('communications.settings.messages.testError') });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -66,7 +68,7 @@ export default function SettingsPage() {
return ( return (
<Box p={3}> <Box p={3}>
<Typography variant="h4" gutterBottom display="flex" alignItems="center" gap={2}> <Typography variant="h4" gutterBottom display="flex" alignItems="center" gap={2}>
<Email fontSize="large" color="primary" /> Configurazione Email <Email fontSize="large" color="primary" /> {t('communications.settings.title')}
</Typography> </Typography>
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ p: 3, mb: 3 }}>
@@ -74,13 +76,13 @@ export default function SettingsPage() {
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Provider</InputLabel> <InputLabel>{t('communications.settings.fields.provider')}</InputLabel>
<Controller <Controller
name="provider" name="provider"
control={control} control={control}
defaultValue="smtp" defaultValue="smtp"
render={({ field }) => ( render={({ field }) => (
<Select {...field} label="Provider"> <Select {...field} label={t('communications.settings.fields.provider')}>
<MenuItem value="smtp">SMTP</MenuItem> <MenuItem value="smtp">SMTP</MenuItem>
<MenuItem value="resend">Resend</MenuItem> <MenuItem value="resend">Resend</MenuItem>
</Select> </Select>
@@ -96,7 +98,7 @@ export default function SettingsPage() {
name="host" name="host"
control={control} control={control}
defaultValue="" defaultValue=""
render={({ field }) => <TextField {...field} label="SMTP Host" fullWidth required />} render={({ field }) => <TextField {...field} label={t('communications.settings.fields.host')} fullWidth required />}
/> />
</Grid> </Grid>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
@@ -104,7 +106,7 @@ export default function SettingsPage() {
name="port" name="port"
control={control} control={control}
defaultValue={587} defaultValue={587}
render={({ field }) => <TextField {...field} label="Port" type="number" fullWidth required />} render={({ field }) => <TextField {...field} label={t('communications.settings.fields.port')} type="number" fullWidth required />}
/> />
</Grid> </Grid>
@@ -113,7 +115,7 @@ export default function SettingsPage() {
name="user" name="user"
control={control} control={control}
defaultValue="" defaultValue=""
render={({ field }) => <TextField {...field} label="Username" fullWidth />} render={({ field }) => <TextField {...field} label={t('communications.settings.fields.user')} fullWidth />}
/> />
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
@@ -121,7 +123,7 @@ export default function SettingsPage() {
name="password" name="password"
control={control} control={control}
defaultValue="" defaultValue=""
render={({ field }) => <TextField {...field} label="Password" type="password" fullWidth />} render={({ field }) => <TextField {...field} label={t('communications.settings.fields.password')} type="password" fullWidth />}
/> />
</Grid> </Grid>
@@ -133,7 +135,7 @@ export default function SettingsPage() {
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<FormControlLabel <FormControlLabel
control={<Switch checked={value} onChange={onChange} />} control={<Switch checked={value} onChange={onChange} />}
label="Enable SSL/TLS" label={t('communications.settings.fields.ssl')}
/> />
)} )}
/> />
@@ -147,17 +149,17 @@ export default function SettingsPage() {
name="resendApiKey" name="resendApiKey"
control={control} control={control}
defaultValue="" defaultValue=""
render={({ field }) => <TextField {...field} label="Resend API Key" type="password" fullWidth required />} render={({ field }) => <TextField {...field} label={t('communications.settings.fields.apiKey')} type="password" fullWidth required />}
/> />
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}> <Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}>
Ottieni la tua API Key su <a href="https://resend.com/api-keys" target="_blank" rel="noopener noreferrer">resend.com</a> {t('communications.settings.helpers.apiKey')} <a href="https://resend.com/api-keys" target="_blank" rel="noopener noreferrer">resend.com</a>
</Typography> </Typography>
</Grid> </Grid>
)} )}
<Grid item xs={12}> <Grid item xs={12}>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Typography variant="h6">Mittente Default</Typography> <Typography variant="h6">{t('communications.settings.sections.defaultSender')}</Typography>
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
@@ -165,7 +167,7 @@ export default function SettingsPage() {
name="fromEmail" name="fromEmail"
control={control} control={control}
defaultValue="" defaultValue=""
render={({ field }) => <TextField {...field} label="From Email" fullWidth required />} render={({ field }) => <TextField {...field} label={t('communications.settings.fields.fromEmail')} fullWidth required />}
/> />
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
@@ -173,7 +175,7 @@ export default function SettingsPage() {
name="fromName" name="fromName"
control={control} control={control}
defaultValue="" defaultValue=""
render={({ field }) => <TextField {...field} label="From Name" fullWidth />} render={({ field }) => <TextField {...field} label={t('communications.settings.fields.fromName')} fullWidth />}
/> />
</Grid> </Grid>
@@ -183,7 +185,7 @@ export default function SettingsPage() {
startIcon={<Send />} startIcon={<Send />}
onClick={() => setTestMode(!testMode)} onClick={() => setTestMode(!testMode)}
> >
Test Connessione {t('communications.settings.actions.testConnection')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -191,7 +193,7 @@ export default function SettingsPage() {
startIcon={<Save />} startIcon={<Save />}
disabled={loading} disabled={loading}
> >
Salva Configurazione {t('common.save')}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>
@@ -200,11 +202,11 @@ export default function SettingsPage() {
{testMode && ( {testMode && (
<Paper sx={{ p: 3, bgcolor: '#f5f5f5' }}> <Paper sx={{ p: 3, bgcolor: '#f5f5f5' }}>
<Typography variant="h6" gutterBottom>Test Email</Typography> <Typography variant="h6" gutterBottom>{t('communications.settings.testStats.title')}</Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<TextField <TextField
label="Destinatario" label={t('communications.settings.testStats.recipient')}
fullWidth fullWidth
value={testData.to} value={testData.to}
onChange={(e) => setTestData({ ...testData, to: e.target.value })} onChange={(e) => setTestData({ ...testData, to: e.target.value })}
@@ -212,7 +214,7 @@ export default function SettingsPage() {
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<TextField <TextField
label="Oggetto" label={t('communications.settings.testStats.subject')}
fullWidth fullWidth
value={testData.subject} value={testData.subject}
onChange={(e) => setTestData({ ...testData, subject: e.target.value })} onChange={(e) => setTestData({ ...testData, subject: e.target.value })}
@@ -220,7 +222,7 @@ export default function SettingsPage() {
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Button variant="contained" color="secondary" onClick={sendTest} disabled={loading}> <Button variant="contained" color="secondary" onClick={sendTest} disabled={loading}>
Invia Test {t('communications.settings.actions.sendTest')}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -11,7 +11,8 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
TextField, TextField,
Tabs,
Tab,
} from "@mui/material"; } from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { import {
@@ -21,10 +22,167 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { clientiService } from "../../../services/lookupService"; import { clientiService } from "../../../services/lookupService";
import { Cliente } from "../../../types"; import { Cliente, ClienteContatto } from "../../../types";
import { CustomFieldsRenderer } from "../../../components/customFields/CustomFieldsRenderer"; import { CustomFieldsRenderer } from "../../../components/customFields/CustomFieldsRenderer";
import { CustomFieldValues } from "../../../types/customFields"; 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() { export default function ClientiPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,6 +190,7 @@ export default function ClientiPage() {
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<Partial<Cliente>>({ attivo: true }); const [formData, setFormData] = useState<Partial<Cliente>>({ attivo: true });
const [customFields, setCustomFields] = useState<CustomFieldValues>({}); const [customFields, setCustomFields] = useState<CustomFieldValues>({});
const [tabValue, setTabValue] = useState(0);
const { data: clienti = [], isLoading } = useQuery({ const { data: clienti = [], isLoading } = useQuery({
queryKey: ["clienti"], queryKey: ["clienti"],
@@ -65,6 +224,7 @@ export default function ClientiPage() {
setEditingId(null); setEditingId(null);
setFormData({ attivo: true }); setFormData({ attivo: true });
setCustomFields({}); setCustomFields({});
setTabValue(0);
}; };
const handleEdit = (cliente: Cliente) => { const handleEdit = (cliente: Cliente) => {
@@ -85,11 +245,9 @@ export default function ClientiPage() {
}; };
if (editingId) { if (editingId) {
// In modifica, non inviamo il codice (non modificabile)
const { codice: _codice, ...updateData } = dataWithCustomFields; const { codice: _codice, ...updateData } = dataWithCustomFields;
updateMutation.mutate({ id: editingId, data: updateData }); updateMutation.mutate({ id: editingId, data: updateData });
} else { } else {
// In creazione, non inviamo il codice (generato automaticamente)
const { codice: _codice, ...createData } = dataWithCustomFields; const { codice: _codice, ...createData } = dataWithCustomFields;
createMutation.mutate(createData); createMutation.mutate(createData);
} }
@@ -178,7 +336,15 @@ export default function ClientiPage() {
{editingId ? t("clients.editClient") : t("clients.newClient")} {editingId ? t("clients.editClient") : t("clients.newClient")}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<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}> <Box display="flex" flexWrap="wrap" gap={2} mt={1}>
{/* EXISTING FIELDS */}
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}> <Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
<TextField <TextField
label={t("clients.code")} label={t("clients.code")}
@@ -358,12 +524,21 @@ export default function ClientiPage() {
/> />
</Box> </Box>
</Box> </Box>
)}
</Box>
<Box role="tabpanel" hidden={tabValue !== 1}>
{tabValue === 1 && editingId && <ContactsManager clienteId={editingId} />}
</Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button> <Button onClick={handleCloseDialog}>{t("common.close")}</Button>
{tabValue === 0 && (
<Button variant="contained" onClick={handleSubmit}> <Button variant="contained" onClick={handleSubmit}>
{editingId ? t("common.save") : t("common.create")} {editingId ? t("common.save") : t("common.create")}
</Button> </Button>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,18 @@
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";
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>
</Routes>
);
}

View 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`);
}
};

View File

@@ -105,6 +105,7 @@ export default function ArticleFormPage() {
isSerialManaged: false, isSerialManaged: false,
hasExpiry: false, hasExpiry: false,
expiryWarningDays: 30, expiryWarningDays: 30,
giorniValidita: undefined as number | undefined,
isActive: true, isActive: true,
notes: "", notes: "",
}); });
@@ -159,6 +160,7 @@ export default function ArticleFormPage() {
isSerialManaged: article.isSerialManaged, isSerialManaged: article.isSerialManaged,
hasExpiry: article.hasExpiry, hasExpiry: article.hasExpiry,
expiryWarningDays: article.expiryWarningDays || 30, expiryWarningDays: article.expiryWarningDays || 30,
giorniValidita: article.giorniValidita,
isActive: article.isActive, isActive: article.isActive,
notes: article.notes || "", notes: article.notes || "",
}); });
@@ -234,6 +236,7 @@ export default function ArticleFormPage() {
isSerialManaged: formData.isSerialManaged, isSerialManaged: formData.isSerialManaged,
hasExpiry: formData.hasExpiry, hasExpiry: formData.hasExpiry,
expiryWarningDays: formData.expiryWarningDays, expiryWarningDays: formData.expiryWarningDays,
giorniValidita: formData.giorniValidita,
notes: formData.notes || undefined, notes: formData.notes || undefined,
}; };
const result = await createMutation.mutateAsync(createData); const result = await createMutation.mutateAsync(createData);
@@ -258,6 +261,7 @@ export default function ArticleFormPage() {
isSerialManaged: formData.isSerialManaged, isSerialManaged: formData.isSerialManaged,
hasExpiry: formData.hasExpiry, hasExpiry: formData.hasExpiry,
expiryWarningDays: formData.expiryWarningDays, expiryWarningDays: formData.expiryWarningDays,
giorniValidita: formData.giorniValidita,
isActive: formData.isActive, isActive: formData.isActive,
notes: formData.notes || undefined, notes: formData.notes || undefined,
}; };
@@ -625,6 +629,20 @@ export default function ArticleFormPage() {
label={t("warehouse.articleForm.fields.expiryManaged")} label={t("warehouse.articleForm.fields.expiryManaged")}
/> />
</Grid> </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}> <Grid size={12}>
<FormControlLabel <FormControlLabel
control={ control={

View 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>
);
}

View File

@@ -4,6 +4,7 @@ export { default as WarehouseDashboard } from './WarehouseDashboard';
// Articles // Articles
export { default as ArticlesPage } from './ArticlesPage'; export { default as ArticlesPage } from './ArticlesPage';
export { default as ArticleFormPage } from './ArticleFormPage'; export { default as ArticleFormPage } from './ArticleFormPage';
export { default as CategoriesPage } from './CategoriesPage';
// Warehouse Locations // Warehouse Locations
export { default as WarehouseLocationsPage } from './WarehouseLocationsPage'; export { default as WarehouseLocationsPage } from './WarehouseLocationsPage';

View File

@@ -4,6 +4,7 @@ import {
WarehouseDashboard, WarehouseDashboard,
ArticlesPage, ArticlesPage,
ArticleFormPage, ArticleFormPage,
CategoriesPage,
WarehouseLocationsPage, WarehouseLocationsPage,
MovementsPage, MovementsPage,
InboundMovementPage, InboundMovementPage,
@@ -30,6 +31,7 @@ export default function WarehouseRoutes() {
<Route path="articles/new" element={<ArticleFormPage />} /> <Route path="articles/new" element={<ArticleFormPage />} />
<Route path="articles/:id" element={<ArticleFormPage />} /> <Route path="articles/:id" element={<ArticleFormPage />} />
<Route path="articles/:id/edit" element={<ArticleFormPage />} /> <Route path="articles/:id/edit" element={<ArticleFormPage />} />
<Route path="categories" element={<CategoriesPage />} />
{/* Warehouse Locations */} {/* Warehouse Locations */}
<Route path="locations" element={<WarehouseLocationsPage />} /> <Route path="locations" element={<WarehouseLocationsPage />} />

View File

@@ -558,6 +558,7 @@ export const inventoryService = {
}, },
}; };
// Export all services // Export all services
export default { export default {
locations: warehouseLocationService, locations: warehouseLocationService,

View File

@@ -230,6 +230,7 @@ export interface UpdateCategoryDto {
notes?: string; notes?: string;
} }
// =============================================== // ===============================================
// ARTICLE // ARTICLE
// =============================================== // ===============================================
@@ -252,6 +253,7 @@ export interface ArticleDto {
isSerialManaged: boolean; isSerialManaged: boolean;
hasExpiry: boolean; hasExpiry: boolean;
expiryWarningDays?: number; expiryWarningDays?: number;
giorniValidita?: number;
minimumStock?: number; minimumStock?: number;
maximumStock?: number; maximumStock?: number;
reorderPoint?: number; reorderPoint?: number;
@@ -287,6 +289,7 @@ export interface CreateArticleDto {
isSerialManaged: boolean; isSerialManaged: boolean;
hasExpiry: boolean; hasExpiry: boolean;
expiryWarningDays?: number; expiryWarningDays?: number;
giorniValidita?: number;
minimumStock?: number; minimumStock?: number;
maximumStock?: number; maximumStock?: number;
reorderPoint?: number; reorderPoint?: number;
@@ -316,6 +319,7 @@ export interface UpdateArticleDto {
isSerialManaged: boolean; isSerialManaged: boolean;
hasExpiry: boolean; hasExpiry: boolean;
expiryWarningDays?: number; expiryWarningDays?: number;
giorniValidita?: number;
minimumStock?: number; minimumStock?: number;
maximumStock?: number; maximumStock?: number;
reorderPoint?: number; reorderPoint?: number;

View File

@@ -64,14 +64,14 @@ export default function SearchBar() {
const options = useMemo(() => { const options = useMemo(() => {
const opts: SearchOption[] = [ const opts: SearchOption[] = [
// Core // Core
{ label: t('menu.dashboard'), path: '/', category: 'Zentral', translationKey: 'menu.dashboard' }, { label: t('menu.dashboard'), path: '/', category: t('apps.core.title'), translationKey: 'menu.dashboard' },
{ label: t('menu.calendar'), path: '/calendario', category: 'Zentral', translationKey: 'menu.calendar' }, { label: t('menu.calendar'), path: '/calendario', category: t('apps.core.title'), translationKey: 'menu.calendar' },
{ label: t('menu.events'), path: '/eventi', category: 'Zentral', translationKey: 'menu.events' }, { label: t('menu.events'), path: '/eventi', category: t('apps.core.title'), translationKey: 'menu.events' },
{ label: t('menu.clients'), path: '/clienti', category: 'Zentral', translationKey: 'menu.clients' }, { label: t('menu.clients'), path: '/clienti', category: t('apps.core.title'), translationKey: 'menu.clients' },
{ label: t('menu.location'), path: '/location', category: 'Zentral', translationKey: 'menu.location' }, { label: t('menu.location'), path: '/location', category: t('apps.core.title'), translationKey: 'menu.location' },
{ label: t('menu.articles'), path: '/articoli', category: 'Zentral', translationKey: 'menu.articles' }, { label: t('menu.articles'), path: '/articoli', category: t('apps.core.title'), translationKey: 'menu.articles' },
{ label: t('menu.resources'), path: '/risorse', category: 'Zentral', translationKey: 'menu.resources' }, { label: t('menu.resources'), path: '/risorse', category: t('apps.core.title'), translationKey: 'menu.resources' },
{ label: t('menu.reports'), path: '/report-templates', category: 'Zentral', translationKey: 'menu.reports' }, { label: t('menu.reports'), path: '/report-templates', category: t('apps.core.title'), translationKey: 'menu.reports' },
]; ];
if (activeAppCodes.includes('warehouse')) { 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( opts.push(
{ label: t('menu.apps'), path: '/apps', category: t('menu.administration'), translationKey: 'menu.apps' }, { 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' }, { label: t('menu.autoCodes'), path: '/admin/auto-codes', category: t('menu.administration'), translationKey: 'menu.autoCodes' },

View File

@@ -36,11 +36,13 @@ import {
Timeline as TimelineIcon, Timeline as TimelineIcon,
PrecisionManufacturing as ManufacturingIcon, PrecisionManufacturing as ManufacturingIcon,
Category as CategoryIcon, Category as CategoryIcon,
Folder as FolderIcon,
AttachMoney as AttachMoneyIcon, AttachMoney as AttachMoneyIcon,
Receipt as ReceiptIcon, Receipt as ReceiptIcon,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Email as EmailIcon, Email as EmailIcon,
School as SchoolIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -77,6 +79,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
production: false, production: false,
events: false, events: false,
hr: false, hr: false,
training: false,
admin: false, admin: false,
}); });
@@ -102,7 +105,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
const menuStructure: MenuItem[] = [ const menuStructure: MenuItem[] = [
{ {
id: 'dashboard', id: 'dashboard',
label: 'Zentral Dashboard', label: t('menu.dashboard'),
icon: <DashboardIcon />, icon: <DashboardIcon />,
path: '/', path: '/',
translationKey: 'menu.dashboard', translationKey: 'menu.dashboard',
@@ -116,6 +119,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
children: [ children: [
{ id: 'wh-dashboard', label: t('menu.dashboard'), tabLabel: t('menu.warehouse'), icon: <DashboardIcon />, path: '/warehouse', translationKey: 'menu.warehouse' }, { 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-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-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-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' }, { id: 'wh-stock', label: t('menu.stock'), icon: <StorageIcon />, path: '/warehouse/stock', translationKey: 'menu.stock' },
@@ -184,6 +188,18 @@ 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: '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-matrix', label: t('apps.training.matrix'), icon: <AssignmentIcon />, path: '/training/matrix', translationKey: 'apps.training.matrix' },
],
},
{ {
id: 'admin', id: 'admin',
label: t('menu.administration'), label: t('menu.administration'),

View File

@@ -1,5 +1,5 @@
import api from './api'; import api from './api';
import { Cliente, Location, Risorsa, Articolo, LookupItem } from '../types'; import { Cliente, Location, Risorsa, Articolo, LookupItem, ClienteContatto } from '../types';
export const lookupService = { export const lookupService = {
getTipiEvento: async () => { getTipiEvento: async () => {
@@ -72,6 +72,20 @@ export const clientiService = {
delete: async (id: number) => { delete: async (id: number) => {
await api.delete(`/clienti/${id}`); 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 = { export const locationService = {
@@ -117,7 +131,7 @@ export const risorseService = {
}; };
export const articoliService = { 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 }); const { data } = await api.get<Articolo[]>('/articoli', { params });
return data; return data;
}, },

View File

@@ -4,6 +4,12 @@ export enum StatoEvento {
Confermato = 20, Confermato = 20,
} }
export enum TipoArticolo {
Standard = 0,
Corso = 1,
Servizio = 2,
}
export interface BaseEntity { export interface BaseEntity {
id: number; id: number;
createdAt?: string; createdAt?: string;
@@ -29,6 +35,16 @@ export interface Cliente extends BaseEntity {
codiceDestinatario?: string; codiceDestinatario?: string;
note?: string; note?: string;
attivo: boolean; 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 { export interface Location extends BaseEntity {
@@ -105,6 +121,8 @@ export interface Articolo extends BaseEntity {
unitaMisura?: string; unitaMisura?: string;
note?: string; note?: string;
attivo: boolean; attivo: boolean;
giorniValidita?: number;
tipo?: TipoArticolo;
} }
export interface Evento extends BaseEntity { export interface Evento extends BaseEntity {
@@ -294,3 +312,15 @@ export interface LookupItem {
citta?: string; citta?: string;
tipo?: 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
}