Compare commits
7 Commits
54cf1ff276
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 99ce5e1e6a | |||
| 4810d49410 | |||
| 49abef6f96 | |||
| 64d93a936c | |||
| 0314b40f92 | |||
| c4d58f8354 | |||
| 08256f0019 |
27
.agent/rules/customizations-folders.md
Normal file
27
.agent/rules/customizations-folders.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
trigger: model_decision
|
||||||
|
description: Quando è richiesta una feature specifica per un cliente, non inerente allo standard
|
||||||
|
---
|
||||||
|
|
||||||
|
usa ./docs/development/devlog/customizations per tenere traccia di tutti i piani di lavoro custom e il loro attuale stato singolarmente, crea qui dentro i log delle lavorazioni ed il lavoro fatto, da fare e suggerito per ogni piano di sviluppo, usa il formato "yyyy-mm-dd-hh24miss_descrizione_brevissima".
|
||||||
|
|
||||||
|
usa ./docs/development per tenere un file ZENTRAL_CUSTOM.md riassuntivo con link ai file specifici dentro ./docs/development/devlog/customizations e una breve sintesi specificando che tipo di sviluppo si è concluso o si sta lavorando.
|
||||||
|
|
||||||
|
## Struttura Modulare del Progetto Custom
|
||||||
|
|
||||||
|
Per ogni modulo custom specificatamente sviluppato per una richiesta cliente è necessario prima trovare il miglior modo per integrare questo modulo custom il più possibile con i moduli esistenti, evitando di duplicare il codice e permettendo di scrivere meno codice possibile.
|
||||||
|
|
||||||
|
### Backend (.NET)
|
||||||
|
- **API Controllers**: `src/backend/Zentral.API/Modules/Custom/[NomeModulo]/Controllers/`
|
||||||
|
- I controller devono avere il namespace `Zentral.API.Modules.[NomeModulo].Controllers`.
|
||||||
|
- Le rotte devono seguire il pattern `api/custom/[nome-modulo]/[controller]`.
|
||||||
|
- **Entities**: `src/backend/Zentral.Domain/Entities/Custom/[NomeModulo]/`
|
||||||
|
- Le entità devono avere il namespace `Zentral.Domain.Entities.Custom.[NomeModulo]`.
|
||||||
|
|
||||||
|
### Frontend (React)
|
||||||
|
- **Moduli**: `src/frontend/src/modules/custom/[nome-modulo]/`
|
||||||
|
- **Pagine**: `src/frontend/src/modules/custom/[nome-modulo]/pages/`
|
||||||
|
- **Componenti**: `src/frontend/src/modules/custom/[nome-modulo]/components/`
|
||||||
|
- **Rotte**: `src/frontend/src/modules/custom/[nome-modulo]/routes.tsx`
|
||||||
|
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
||||||
|
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||||
@@ -27,4 +27,4 @@ Il progetto segue una rigorosa struttura modulare sia per il backend che per il
|
|||||||
- **Componenti**: `src/frontend/src/modules/[nome-modulo]/components/`
|
- **Componenti**: `src/frontend/src/modules/[nome-modulo]/components/`
|
||||||
- **Rotte**: `src/frontend/src/modules/[nome-modulo]/routes.tsx`
|
- **Rotte**: `src/frontend/src/modules/[nome-modulo]/routes.tsx`
|
||||||
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
||||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
trigger: always_on
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Implementazione Gruppi Merceologici Magazzino
|
||||||
|
|
||||||
|
## Richiesta
|
||||||
|
Implementare la gestione dei gruppi merceologici per la categorizzazione degli articoli nel modulo magazzino, sia backend che frontend.
|
||||||
|
|
||||||
|
## Stato Attuale
|
||||||
|
- Esiste già una gestione di "Categorie Articoli" (`WarehouseArticleCategory`) che è gerarchica.
|
||||||
|
- "Gruppi Merceologici" (`WarehouseProductGroup`) sarà una nuova entità, probabilmente una classificazione parallela non gerarchica (o piatta) spesso usata per fini statistici o contabili, o semplicemente come raggruppamento alternativo.
|
||||||
|
|
||||||
|
## Piano di Lavoro
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
1. **Domain Layer**
|
||||||
|
- Creare entità `WarehouseProductGroup` in `Zentral.Domain.Entities.Warehouse`.
|
||||||
|
- Campi: Code, Name, Description, IsActive.
|
||||||
|
- Aggiornare `WarehouseArticle` aggiungendo FK `ProductGroupId` e navigation property.
|
||||||
|
2. **Infrastructure Layer**
|
||||||
|
- Aggiungere `DbSet<WarehouseProductGroup>` in `ApplicationDbContext`.
|
||||||
|
- Configurare le relazioni entity framework se necessario.
|
||||||
|
- Creare Migrazione `AddWarehouseProductGroups`.
|
||||||
|
3. **Service Layer**
|
||||||
|
- Aggiornare `IWarehouseService` e `WarehouseService` con i metodi CRUD per i gruppi merceologici.
|
||||||
|
4. **API Layer**
|
||||||
|
- Creare `WarehouseProductGroupsController`.
|
||||||
|
- Aggiornare DTOs degli articoli per includere `ProductGroupId`.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. **Services**
|
||||||
|
- Creare `productGroupService.ts` per chiamare le API.
|
||||||
|
2. **Pages**
|
||||||
|
- Creare `ProductGroupsPage` per elenco e gestione (CRUD).
|
||||||
|
3. **Components**
|
||||||
|
- Aggiornare il form di creazione/modifica articolo per permettere la selezione del gruppo merceologico.
|
||||||
|
4. **Routing & Navigation**
|
||||||
|
- Aggiungere rotta per `ProductGroupsPage`.
|
||||||
|
- Aggiungere voce di menu nella sidebar del magazzino.
|
||||||
|
|
||||||
|
## Note
|
||||||
|
- L'implementazione seguirà lo stile esistente del modulo Warehouse, usando Services e Controllers.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Sostituzione Gruppi Merceologici con Categorie Gerarchiche
|
||||||
|
|
||||||
|
## Stato Corrente
|
||||||
|
IMPLEMENTATO
|
||||||
|
|
||||||
|
## Descrizione
|
||||||
|
Sostituita la gestione separata dei "Gruppi Merceologici" con l'utilizzo potenziato delle Categorie Articoli (`WarehouseArticleCategory`) già esistenti e gerarchiche.
|
||||||
|
|
||||||
|
## Modifiche Apportate
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Revert**: Rimossa entity `WarehouseProductGroup` e relativi controller e service.
|
||||||
|
- **Migration**: Creata e applicata migrazione `RemoveWarehouseProductGroups` per rimuovere la tabella dal database.
|
||||||
|
- **Services**: `WarehouseService` ripulito da logica `ProductGroups`.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Revert**: Rimossa pagina `ProductGroupsPage` e riferimenti nel codice.
|
||||||
|
- **New Feature**: Creata pagina `CategoriesPage` (`/warehouse/categories`) per gestire le categorie in modalità albero.
|
||||||
|
- Create
|
||||||
|
- Update
|
||||||
|
- Delete
|
||||||
|
- Struttura gerarchica visualizzata (Tree View).
|
||||||
|
- **Article Form**: Rimossa selezione "Gruppo Merceologico". La selezione della categoria utilizza `CategoryTree` appiattito per la selezione.
|
||||||
|
- **Navigation**: Aggiunto link "Categorie" nella sidebar del Magazzino.
|
||||||
|
|
||||||
|
## Note Tecniche
|
||||||
|
- La gestione delle categorie sfrutta la ricorsività supportata dall'entity `WarehouseArticleCategory`.
|
||||||
|
- L'interfaccia utente permette di gestire la gerarchia creando categorie "root" o sottocategorie.
|
||||||
|
|
||||||
|
## Verifica
|
||||||
|
- **Backend API**:
|
||||||
|
- `GET /api/warehouse/categories` -> Disponibile.
|
||||||
|
- `GET /api/warehouse/categories/tree` -> Disponibile (ritorna JSON corretto).
|
||||||
|
- `GET /api/warehouse/product-groups` -> **404 Not Found** (Correttamente rimosso).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Update Translations for New Developments
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- [x] Analysis of new features needing translation
|
||||||
|
- [x] Update Italian Translations (it)
|
||||||
|
- [x] Update English Translations (en)
|
||||||
|
- [x] Verification
|
||||||
|
|
||||||
|
## Details
|
||||||
|
Verified recent developments:
|
||||||
|
1. **Warehouse - Categories**: New management of article categories.
|
||||||
|
2. **Communications**: Email configuration and logs.
|
||||||
|
3. **Training**: New module for courses and training sessions.
|
||||||
|
|
||||||
|
I will scan these modules for `t()` calls and update the `translation.json` files in `public/locales/it` and `public/locales/en`.
|
||||||
|
|
||||||
|
## Work Done
|
||||||
|
- **Warehouse Categories**: Updated `CategoriesPage.tsx` to use `useTranslation`. Added keys for titles, buttons, fields, and dialogs in both IT and EN locales.
|
||||||
|
- **Communications**: Updated `SettingsPage.tsx` and `LogsPage.tsx` to use `useTranslation`. Added complete set of keys for settings, fields, actions, messages and log columns in both IT and EN locales.
|
||||||
|
- **Components**: Updated `Sidebar.tsx`, `SearchBar.tsx` to use full translations. Added `apps.core.title` and ensure `categories` is available in menu.
|
||||||
|
- **Training**: Training module files were not found in the current workspace, so no translations were applied for this module yet. Suggest to review separately when module is available.
|
||||||
@@ -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
|
||||||
// ===============================================
|
// ===============================================
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/backend/Zentral.Domain/Entities/ClienteContatto.cs
Normal file
12
src/backend/Zentral.Domain/Entities/ClienteContatto.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Zentral.Domain.Entities;
|
||||||
|
|
||||||
|
public class ClienteContatto : BaseEntity
|
||||||
|
{
|
||||||
|
public string Nome { get; set; } = string.Empty;
|
||||||
|
public string Cognome { get; set; } = string.Empty;
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Ruolo { get; set; }
|
||||||
|
public string? Telefono { get; set; }
|
||||||
|
public int ClienteId { get; set; }
|
||||||
|
public Cliente Cliente { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
4823
src/backend/Zentral.Infrastructure/Migrations/20251212115332_AddWarehouseProductGroups.Designer.cs
generated
Normal file
4823
src/backend/Zentral.Infrastructure/Migrations/20251212115332_AddWarehouseProductGroups.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Zentral.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddWarehouseProductGroups : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ProductGroupId",
|
||||||
|
table: "WarehouseArticles",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WarehouseProductGroups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WarehouseProductGroups", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WarehouseArticles_ProductGroupId",
|
||||||
|
table: "WarehouseArticles",
|
||||||
|
column: "ProductGroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WarehouseProductGroups_Code",
|
||||||
|
table: "WarehouseProductGroups",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WarehouseProductGroups_IsActive",
|
||||||
|
table: "WarehouseProductGroups",
|
||||||
|
column: "IsActive");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||||
|
table: "WarehouseArticles",
|
||||||
|
column: "ProductGroupId",
|
||||||
|
principalTable: "WarehouseProductGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||||
|
table: "WarehouseArticles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WarehouseProductGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_WarehouseArticles_ProductGroupId",
|
||||||
|
table: "WarehouseArticles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProductGroupId",
|
||||||
|
table: "WarehouseArticles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4761
src/backend/Zentral.Infrastructure/Migrations/20251212122107_RemoveWarehouseProductGroups.Designer.cs
generated
Normal file
4761
src/backend/Zentral.Infrastructure/Migrations/20251212122107_RemoveWarehouseProductGroups.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Zentral.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveWarehouseProductGroups : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||||
|
table: "WarehouseArticles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "WarehouseProductGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_WarehouseArticles_ProductGroupId",
|
||||||
|
table: "WarehouseArticles");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "WarehouseProductGroups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_WarehouseProductGroups", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WarehouseArticles_ProductGroupId",
|
||||||
|
table: "WarehouseArticles",
|
||||||
|
column: "ProductGroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WarehouseProductGroups_Code",
|
||||||
|
table: "WarehouseProductGroups",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_WarehouseProductGroups_IsActive",
|
||||||
|
table: "WarehouseProductGroups",
|
||||||
|
column: "IsActive");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||||
|
table: "WarehouseArticles",
|
||||||
|
column: "ProductGroupId",
|
||||||
|
principalTable: "WarehouseProductGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4892
src/backend/Zentral.Infrastructure/Migrations/20251212143625_AddTrainingModule.Designer.cs
generated
Normal file
4892
src/backend/Zentral.Infrastructure/Migrations/20251212143625_AddTrainingModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Zentral.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTrainingModule : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "GiorniValidita",
|
||||||
|
table: "Articoli",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ClienteContatti",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Nome = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Cognome = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Email = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Ruolo = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Telefono = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ClienteId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ClienteContatti", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClienteContatti_Clienti_ClienteId",
|
||||||
|
column: x => x.ClienteId,
|
||||||
|
principalTable: "Clienti",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TrainingRecords",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
ClienteContattoId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ArticoloId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
DataEsecuzione = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
DataScadenza = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
AttestatoUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Note = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TrainingRecords", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TrainingRecords_Articoli_ArticoloId",
|
||||||
|
column: x => x.ArticoloId,
|
||||||
|
principalTable: "Articoli",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TrainingRecords_ClienteContatti_ClienteContattoId",
|
||||||
|
column: x => x.ClienteContattoId,
|
||||||
|
principalTable: "ClienteContatti",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClienteContatti_ClienteId",
|
||||||
|
table: "ClienteContatti",
|
||||||
|
column: "ClienteId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TrainingRecords_ArticoloId",
|
||||||
|
table: "TrainingRecords",
|
||||||
|
column: "ArticoloId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TrainingRecords_ClienteContattoId",
|
||||||
|
table: "TrainingRecords",
|
||||||
|
column: "ClienteContattoId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TrainingRecords");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ClienteContatti");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GiorniValidita",
|
||||||
|
table: "Articoli");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4895
src/backend/Zentral.Infrastructure/Migrations/20251212170220_AddTipoArticolo.Designer.cs
generated
Normal file
4895
src/backend/Zentral.Infrastructure/Migrations/20251212170220_AddTipoArticolo.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Zentral.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTipoArticolo : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Tipo",
|
||||||
|
table: "Articoli",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Tipo",
|
||||||
|
table: "Articoli");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,192 +336,209 @@ export default function ClientiPage() {
|
|||||||
{editingId ? t("clients.editClient") : t("clients.newClient")}
|
{editingId ? t("clients.editClient") : t("clients.newClient")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Box display="flex" flexWrap="wrap" gap={2} mt={1}>
|
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} sx={{ mb: 2, borderBottom: 1, borderColor: "divider" }}>
|
||||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
<Tab label={t("common.details")} />
|
||||||
<TextField
|
<Tab label={t("clients.contacts")} disabled={!editingId} />
|
||||||
label={t("clients.code")}
|
</Tabs>
|
||||||
fullWidth
|
|
||||||
value={
|
<Box role="tabpanel" hidden={tabValue !== 0}>
|
||||||
editingId
|
{tabValue === 0 && (
|
||||||
? formData.codice || ""
|
<Box display="flex" flexWrap="wrap" gap={2} mt={1}>
|
||||||
: t("clients.generatedOnSave")
|
{/* EXISTING FIELDS */}
|
||||||
}
|
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||||
disabled
|
<TextField
|
||||||
helperText={
|
label={t("clients.code")}
|
||||||
editingId
|
fullWidth
|
||||||
? t("clients.autoGenerated")
|
value={
|
||||||
: t("clients.willBeAssigned")
|
editingId
|
||||||
}
|
? formData.codice || ""
|
||||||
InputProps={{
|
: t("clients.generatedOnSave")
|
||||||
readOnly: true,
|
|
||||||
}}
|
|
||||||
sx={
|
|
||||||
!editingId
|
|
||||||
? {
|
|
||||||
"& .MuiInputBase-input.Mui-disabled": {
|
|
||||||
fontStyle: "italic",
|
|
||||||
color: "text.secondary",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
: undefined
|
disabled
|
||||||
}
|
helperText={
|
||||||
/>
|
editingId
|
||||||
</Box>
|
? t("clients.autoGenerated")
|
||||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
: t("clients.willBeAssigned")
|
||||||
<TextField
|
}
|
||||||
label={t("clients.altCode")}
|
InputProps={{
|
||||||
fullWidth
|
readOnly: true,
|
||||||
value={formData.codiceAlternativo || ""}
|
}}
|
||||||
onChange={(e) =>
|
sx={
|
||||||
setFormData({
|
!editingId
|
||||||
...formData,
|
? {
|
||||||
codiceAlternativo: e.target.value,
|
"& .MuiInputBase-input.Mui-disabled": {
|
||||||
})
|
fontStyle: "italic",
|
||||||
}
|
color: "text.secondary",
|
||||||
helperText={t("common.optional")}
|
},
|
||||||
/>
|
}
|
||||||
</Box>
|
: undefined
|
||||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
}
|
||||||
<TextField
|
/>
|
||||||
label={t("clients.businessName")}
|
</Box>
|
||||||
fullWidth
|
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||||
required
|
<TextField
|
||||||
value={formData.ragioneSociale || ""}
|
label={t("clients.altCode")}
|
||||||
onChange={(e) =>
|
fullWidth
|
||||||
setFormData({ ...formData, ragioneSociale: e.target.value })
|
value={formData.codiceAlternativo || ""}
|
||||||
}
|
onChange={(e) =>
|
||||||
/>
|
setFormData({
|
||||||
</Box>
|
...formData,
|
||||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
codiceAlternativo: e.target.value,
|
||||||
<TextField
|
})
|
||||||
label={t("clients.address")}
|
}
|
||||||
fullWidth
|
helperText={t("common.optional")}
|
||||||
value={formData.indirizzo || ""}
|
/>
|
||||||
onChange={(e) =>
|
</Box>
|
||||||
setFormData({ ...formData, indirizzo: e.target.value })
|
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||||
}
|
<TextField
|
||||||
/>
|
label={t("clients.businessName")}
|
||||||
</Box>
|
fullWidth
|
||||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
required
|
||||||
<TextField
|
value={formData.ragioneSociale || ""}
|
||||||
label={t("clients.zip")}
|
onChange={(e) =>
|
||||||
fullWidth
|
setFormData({ ...formData, ragioneSociale: e.target.value })
|
||||||
value={formData.cap || ""}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setFormData({ ...formData, cap: e.target.value })
|
</Box>
|
||||||
}
|
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||||
/>
|
<TextField
|
||||||
</Box>
|
label={t("clients.address")}
|
||||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
fullWidth
|
||||||
<TextField
|
value={formData.indirizzo || ""}
|
||||||
label={t("clients.city")}
|
onChange={(e) =>
|
||||||
fullWidth
|
setFormData({ ...formData, indirizzo: e.target.value })
|
||||||
value={formData.citta || ""}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setFormData({ ...formData, citta: e.target.value })
|
</Box>
|
||||||
}
|
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||||
/>
|
<TextField
|
||||||
</Box>
|
label={t("clients.zip")}
|
||||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
fullWidth
|
||||||
<TextField
|
value={formData.cap || ""}
|
||||||
label={t("clients.province")}
|
onChange={(e) =>
|
||||||
fullWidth
|
setFormData({ ...formData, cap: e.target.value })
|
||||||
value={formData.provincia || ""}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setFormData({ ...formData, provincia: e.target.value })
|
</Box>
|
||||||
}
|
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||||
/>
|
<TextField
|
||||||
</Box>
|
label={t("clients.city")}
|
||||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
fullWidth
|
||||||
<TextField
|
value={formData.citta || ""}
|
||||||
label={t("clients.phone")}
|
onChange={(e) =>
|
||||||
fullWidth
|
setFormData({ ...formData, citta: e.target.value })
|
||||||
value={formData.telefono || ""}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setFormData({ ...formData, telefono: e.target.value })
|
</Box>
|
||||||
}
|
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||||
/>
|
<TextField
|
||||||
</Box>
|
label={t("clients.province")}
|
||||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
fullWidth
|
||||||
<TextField
|
value={formData.provincia || ""}
|
||||||
label={t("clients.email")}
|
onChange={(e) =>
|
||||||
fullWidth
|
setFormData({ ...formData, provincia: e.target.value })
|
||||||
type="email"
|
}
|
||||||
value={formData.email || ""}
|
/>
|
||||||
onChange={(e) =>
|
</Box>
|
||||||
setFormData({ ...formData, email: e.target.value })
|
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||||
}
|
<TextField
|
||||||
/>
|
label={t("clients.phone")}
|
||||||
</Box>
|
fullWidth
|
||||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
value={formData.telefono || ""}
|
||||||
<TextField
|
onChange={(e) =>
|
||||||
label={t("clients.pec")}
|
setFormData({ ...formData, telefono: e.target.value })
|
||||||
fullWidth
|
}
|
||||||
value={formData.pec || ""}
|
/>
|
||||||
onChange={(e) =>
|
</Box>
|
||||||
setFormData({ ...formData, pec: e.target.value })
|
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||||
}
|
<TextField
|
||||||
/>
|
label={t("clients.email")}
|
||||||
</Box>
|
fullWidth
|
||||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
type="email"
|
||||||
<TextField
|
value={formData.email || ""}
|
||||||
label={t("clients.fiscalCode")}
|
onChange={(e) =>
|
||||||
fullWidth
|
setFormData({ ...formData, email: e.target.value })
|
||||||
value={formData.codiceFiscale || ""}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setFormData({ ...formData, codiceFiscale: e.target.value })
|
</Box>
|
||||||
}
|
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||||
/>
|
<TextField
|
||||||
</Box>
|
label={t("clients.pec")}
|
||||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
fullWidth
|
||||||
<TextField
|
value={formData.pec || ""}
|
||||||
label={t("clients.vat")}
|
onChange={(e) =>
|
||||||
fullWidth
|
setFormData({ ...formData, pec: e.target.value })
|
||||||
value={formData.partitaIva || ""}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setFormData({ ...formData, partitaIva: e.target.value })
|
</Box>
|
||||||
}
|
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||||
/>
|
<TextField
|
||||||
</Box>
|
label={t("clients.fiscalCode")}
|
||||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
fullWidth
|
||||||
<TextField
|
value={formData.codiceFiscale || ""}
|
||||||
label={t("clients.recipientCode")}
|
onChange={(e) =>
|
||||||
fullWidth
|
setFormData({ ...formData, codiceFiscale: e.target.value })
|
||||||
value={formData.codiceDestinatario || ""}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
setFormData({
|
</Box>
|
||||||
...formData,
|
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||||
codiceDestinatario: e.target.value,
|
<TextField
|
||||||
})
|
label={t("clients.vat")}
|
||||||
}
|
fullWidth
|
||||||
/>
|
value={formData.partitaIva || ""}
|
||||||
</Box>
|
onChange={(e) =>
|
||||||
<Box flexBasis="100%">
|
setFormData({ ...formData, partitaIva: e.target.value })
|
||||||
<TextField
|
}
|
||||||
label={t("common.notes")}
|
/>
|
||||||
fullWidth
|
</Box>
|
||||||
multiline
|
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||||
rows={3}
|
<TextField
|
||||||
value={formData.note || ""}
|
label={t("clients.recipientCode")}
|
||||||
onChange={(e) =>
|
fullWidth
|
||||||
setFormData({ ...formData, note: e.target.value })
|
value={formData.codiceDestinatario || ""}
|
||||||
}
|
onChange={(e) =>
|
||||||
/>
|
setFormData({
|
||||||
</Box>
|
...formData,
|
||||||
<Box flexBasis="100%">
|
codiceDestinatario: e.target.value,
|
||||||
<CustomFieldsRenderer
|
})
|
||||||
entityName="Cliente"
|
}
|
||||||
values={customFields}
|
/>
|
||||||
onChange={(field, value) => setCustomFields(prev => ({ ...prev, [field]: value }))}
|
</Box>
|
||||||
/>
|
<Box flexBasis="100%">
|
||||||
</Box>
|
<TextField
|
||||||
|
label={t("common.notes")}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={formData.note || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, note: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box flexBasis="100%">
|
||||||
|
<CustomFieldsRenderer
|
||||||
|
entityName="Cliente"
|
||||||
|
values={customFields}
|
||||||
|
onChange={(field, value) => setCustomFields(prev => ({ ...prev, [field]: value }))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<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>
|
||||||
<Button variant="contained" onClick={handleSubmit}>
|
{tabValue === 0 && (
|
||||||
{editingId ? t("common.save") : t("common.create")}
|
<Button variant="contained" onClick={handleSubmit}>
|
||||||
</Button>
|
{editingId ? t("common.save") : t("common.create")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
13
src/frontend/src/apps/training/components/TrainingLayout.tsx
Normal file
13
src/frontend/src/apps/training/components/TrainingLayout.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
export default function TrainingLayout() {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", p: 2 }}>
|
||||||
|
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
112
src/frontend/src/apps/training/pages/DashboardPage.tsx
Normal file
112
src/frontend/src/apps/training/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Tooltip
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Send as SendIcon } from "@mui/icons-material";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { trainingService } from "../services/trainingService";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data: expiringRecords = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["training", "expiring"],
|
||||||
|
queryFn: () => trainingService.getExpiring(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifyMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => trainingService.sendNotification(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
alert(t("training.notificationSent"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiredCount = expiringRecords.filter((r: any) => r.stato === 2).length;
|
||||||
|
const expiringCount = expiringRecords.filter((r: any) => r.stato === 1).length;
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{ field: "dataScadenza", headerName: t("training.expirationDate"), width: 120, valueFormatter: (params: any) => new Date(params.value).toLocaleDateString() },
|
||||||
|
{ field: "course", headerName: t("training.course"), width: 200, valueGetter: (params: any) => params.row.articolo?.descrizione },
|
||||||
|
{ field: "participant", headerName: t("training.participant"), width: 200, valueGetter: (params: any) => `${params.row.clienteContatto?.cognome} ${params.row.clienteContatto?.nome}` },
|
||||||
|
{
|
||||||
|
field: "stato",
|
||||||
|
headerName: t("training.status"),
|
||||||
|
width: 120,
|
||||||
|
renderCell: (params: any) => (
|
||||||
|
params.row.stato === 2
|
||||||
|
? <Chip label={t("training.expired")} color="error" size="small" />
|
||||||
|
: <Chip label={t("training.expiring")} color="warning" size="small" />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: t("common.actions"),
|
||||||
|
width: 100,
|
||||||
|
renderCell: (params: any) => (
|
||||||
|
<Tooltip title={t("training.sendNotification")}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => notifyMutation.mutate(params.row.id)}
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p={2}>
|
||||||
|
<Typography variant="h4" mb={3}>{t("training.dashboard")}</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3} mb={4}>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="textSecondary" gutterBottom>
|
||||||
|
{t("training.expired")}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" color="error">
|
||||||
|
{expiredCount}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="textSecondary" gutterBottom>
|
||||||
|
{t("training.expiring")}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3" color="warning.main">
|
||||||
|
{expiringCount}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Typography variant="h5" mb={2}>{t("training.expiring")}</Typography>
|
||||||
|
<Paper sx={{ height: 400, width: "100%" }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={expiringRecords}
|
||||||
|
columns={columns}
|
||||||
|
loading={isLoading}
|
||||||
|
pageSizeOptions={[5, 10]}
|
||||||
|
initialState={{ pagination: { paginationModel: { pageSize: 5 } } }}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
src/frontend/src/apps/training/pages/MatrixPage.tsx
Normal file
319
src/frontend/src/apps/training/pages/MatrixPage.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Chip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
UploadFile as UploadIcon,
|
||||||
|
Description as DescriptionIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { trainingService } from "../services/trainingService";
|
||||||
|
import { TrainingRecord, ClienteContatto, Articolo, TipoArticolo } from "../../../types";
|
||||||
|
import { lookupService, articoliService, clientiService } from "../../../services/lookupService";
|
||||||
|
|
||||||
|
export default function MatrixPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [formData, setFormData] = useState<Partial<TrainingRecord>>({});
|
||||||
|
const [selectedClient, setSelectedClient] = useState<number | null>(null);
|
||||||
|
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: records = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["training", "records"],
|
||||||
|
queryFn: () => trainingService.getAll(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: customers = [] } = useQuery({
|
||||||
|
queryKey: ["lookup", "customers"],
|
||||||
|
queryFn: () => lookupService.getClienti(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Removed unused trainingCategoryId logic */
|
||||||
|
const { data: courses = [] } = useQuery({
|
||||||
|
queryKey: ["articles", "training"],
|
||||||
|
queryFn: () => articoliService.getAll({ tipo: TipoArticolo.Corso }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: contacts = [] } = useQuery({
|
||||||
|
queryKey: ["contacts", selectedClient],
|
||||||
|
queryFn: () => selectedClient ? clientiService.getContatti(selectedClient) : [],
|
||||||
|
enabled: !!selectedClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (data: Partial<TrainingRecord>) => {
|
||||||
|
const result = await trainingService.create(data);
|
||||||
|
if (fileToUpload) {
|
||||||
|
await trainingService.uploadCertificate(result.id, fileToUpload);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["training", "records"] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, data }: { id: number; data: Partial<TrainingRecord> }) => {
|
||||||
|
await trainingService.update(id, data);
|
||||||
|
if (fileToUpload) {
|
||||||
|
await trainingService.uploadCertificate(id, fileToUpload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["training", "records"] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => trainingService.delete(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["training", "records"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setOpenDialog(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({});
|
||||||
|
setSelectedClient(null);
|
||||||
|
setFileToUpload(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (record: TrainingRecord) => {
|
||||||
|
setFormData(record);
|
||||||
|
setEditingId(record.id);
|
||||||
|
|
||||||
|
// Reverse lookup client from contact if possible?
|
||||||
|
// Not directly available in record unless included.
|
||||||
|
// Record has ClientContatto -> which has ClienteId (if included by backend).
|
||||||
|
// Assuming backend includes ClienteContatto.
|
||||||
|
if (record.clienteContatto) {
|
||||||
|
setSelectedClient(record.clienteContatto.clienteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: formData });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusChip = (status?: number) => {
|
||||||
|
if (status === 2) return <Chip label={t("training.expired")} color="error" size="small" />;
|
||||||
|
if (status === 1) return <Chip label={t("training.expiring")} color="warning" size="small" />;
|
||||||
|
return <Chip label={t("training.valid")} color="success" size="small" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "dataEsecuzione",
|
||||||
|
headerName: t("training.recordDate"),
|
||||||
|
width: 110,
|
||||||
|
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "dataScadenza",
|
||||||
|
headerName: t("training.expirationDate"),
|
||||||
|
width: 110,
|
||||||
|
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "articolo",
|
||||||
|
headerName: t("training.course"),
|
||||||
|
width: 200,
|
||||||
|
valueGetter: (params: any) => params.row.articolo?.descrizione || ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "clienteContatto",
|
||||||
|
headerName: t("training.participant"),
|
||||||
|
width: 200,
|
||||||
|
valueGetter: (params: any) => {
|
||||||
|
const c = params.row.clienteContatto;
|
||||||
|
return c ? `${c.cognome} ${c.nome}` : "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "stato",
|
||||||
|
headerName: t("training.status"),
|
||||||
|
width: 120,
|
||||||
|
renderCell: (params: any) => getStatusChip(params.row.stato)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "attestatoUrl",
|
||||||
|
headerName: t("training.certificate"),
|
||||||
|
width: 100,
|
||||||
|
renderCell: (params: any) => params.value ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
href={`/api/training/${params.row.id}/attestato`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<DescriptionIcon />
|
||||||
|
</IconButton>
|
||||||
|
) : null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: t("common.actions"),
|
||||||
|
width: 120,
|
||||||
|
renderCell: (params: any) => (
|
||||||
|
<Box>
|
||||||
|
<IconButton size="small" onClick={() => handleEdit(params.row)}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t("common.deleteConfirm"))) {
|
||||||
|
deleteMutation.mutate(params.row.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p={2}>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h4">{t("training.matrix")}</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setOpenDialog(true)}
|
||||||
|
>
|
||||||
|
{t("training.newTraining")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper sx={{ height: 600, width: "100%" }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={records}
|
||||||
|
columns={columns}
|
||||||
|
loading={isLoading}
|
||||||
|
pageSizeOptions={[10, 25]}
|
||||||
|
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingId ? t("training.editTraining") : t("training.newTraining")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box display="flex" flexDirection="column" gap={2} mt={1}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label={t("clients.businessName")} // Using client label?
|
||||||
|
value={selectedClient || ""}
|
||||||
|
onChange={(e) => setSelectedClient(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
disabled={!!editingId} // Locked on edit if complex to change
|
||||||
|
>
|
||||||
|
{customers.map((c: any) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>{c.ragioneSociale}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label={t("training.participant")}
|
||||||
|
value={formData.clienteContattoId || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, clienteContattoId: Number(e.target.value) })}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
disabled={!selectedClient}
|
||||||
|
>
|
||||||
|
{contacts.map((c: ClienteContatto) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>{c.cognome} {c.nome}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label={t("training.course")}
|
||||||
|
value={formData.articoloId || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, articoloId: Number(e.target.value) })}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{courses.map((c: Articolo) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>{c.descrizione}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="date"
|
||||||
|
label={t("training.recordDate")}
|
||||||
|
value={formData.dataEsecuzione ? formData.dataEsecuzione.split('T')[0] : ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, dataEsecuzione: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
startIcon={<UploadIcon />}
|
||||||
|
>
|
||||||
|
{t("training.upload")}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => setFileToUpload(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
{fileToUpload && <Typography variant="caption">{fileToUpload.name}</Typography>}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t("common.notes")}
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={formData.note || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||||
|
<Button variant="contained" onClick={handleSubmit}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
src/frontend/src/apps/training/pages/RegistryPage.tsx
Normal file
262
src/frontend/src/apps/training/pages/RegistryPage.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { articoliService } from "../../../services/lookupService";
|
||||||
|
import { categoryService } from "../../warehouse/services/warehouseService";
|
||||||
|
import { Articolo, TipoArticolo } from "../../../types";
|
||||||
|
import { MenuItem } from "@mui/material";
|
||||||
|
|
||||||
|
|
||||||
|
export default function RegistryPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [formData, setFormData] = useState<Partial<Articolo>>({
|
||||||
|
attivo: true,
|
||||||
|
tipo: TipoArticolo.Corso
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Fetch Request ALL Categories
|
||||||
|
const { data: categories = [] } = useQuery({
|
||||||
|
queryKey: ["warehouse-categories"],
|
||||||
|
queryFn: () => categoryService.getAll(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trainingCategoryId = useMemo(() => {
|
||||||
|
return categories.find((c: any) => c.code === "TRAIN")?.id;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
// Find all descendants of TRAIN
|
||||||
|
const allowedCategories = useMemo(() => {
|
||||||
|
if (!trainingCategoryId) return [];
|
||||||
|
|
||||||
|
const descendants: any[] = [];
|
||||||
|
const queue = [trainingCategoryId];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const parentId = queue.shift();
|
||||||
|
const children = categories.filter((c: any) => c.parentCategoryId === parentId);
|
||||||
|
children.forEach((c: any) => {
|
||||||
|
descendants.push(c);
|
||||||
|
queue.push(c.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include TRAIN itself? Maybe better to force using subcategories if they exist,
|
||||||
|
// but allowing TRAIN is flexible.
|
||||||
|
const root = categories.find((c: any) => c.id === trainingCategoryId);
|
||||||
|
return root ? [root, ...descendants] : descendants;
|
||||||
|
}, [categories, trainingCategoryId]);
|
||||||
|
|
||||||
|
|
||||||
|
// 2. Fetch Articles filtered by TipoArticolo.Corso (ignore category filter for list to show all)
|
||||||
|
const { data: articles = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["articles", "training"],
|
||||||
|
queryFn: () => {
|
||||||
|
// We explicitly want ALL courses, regardless of subcategory
|
||||||
|
const params: any = { tipo: TipoArticolo.Corso };
|
||||||
|
return articoliService.getAll(params);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Articolo>) => articoliService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["articles", "training"] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<Articolo> }) =>
|
||||||
|
articoliService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["articles", "training"] });
|
||||||
|
handleCloseDialog();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => articoliService.delete(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["articles", "training"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setOpenDialog(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({ attivo: true, tipo: TipoArticolo.Corso });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (article: Articolo) => {
|
||||||
|
setFormData(article);
|
||||||
|
setEditingId(article.id);
|
||||||
|
setOpenDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!trainingCategoryId) {
|
||||||
|
// Warning but proceed with just Type? User said "linked to warehouse articles but with specific classification".
|
||||||
|
// Classification (Type) is key. Category is secondary but described in plan.
|
||||||
|
// If "TRAIN" category exists we use it, otherwise we rely on Tipo.
|
||||||
|
// But the previous code had an alert.
|
||||||
|
// I'll keep the alert if strict, or maybe auto-create category?
|
||||||
|
// Let's keep strictness on Category if plan required it.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trainingCategoryId) {
|
||||||
|
alert("Errore: Categoria 'Formazione' non trovata. Contattare l'amministratore.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToSave = {
|
||||||
|
...formData,
|
||||||
|
categoriaId: formData.categoriaId || trainingCategoryId, // Use selected or default to TRAIN
|
||||||
|
tipoMaterialeId: 1, // Default Material Type ID
|
||||||
|
unitaMisura: "H", // Hours
|
||||||
|
tipo: TipoArticolo.Corso, // Force Type
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: dataToSave });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(dataToSave);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{ field: "codice", headerName: t("training.course"), width: 120 },
|
||||||
|
{ field: "descrizione", headerName: t("common.description"), flex: 1 },
|
||||||
|
{ field: "giorniValidita", headerName: t("training.validityDays"), width: 150 },
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: t("common.actions"),
|
||||||
|
width: 120,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Box>
|
||||||
|
<IconButton size="small" onClick={() => handleEdit(params.row)}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t("common.deleteConfirm"))) {
|
||||||
|
deleteMutation.mutate(params.row.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!trainingCategoryId && !isLoading && categories.length > 0) {
|
||||||
|
return (
|
||||||
|
<Box p={3}>
|
||||||
|
<Typography color="error">
|
||||||
|
Categoria "Formazione" (TRAIN) non trovata. Eseguire il seed del database.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p={2}>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
|
<Typography variant="h4">{t("training.registry")}</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setOpenDialog(true)}
|
||||||
|
>
|
||||||
|
{t("training.newTraining")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper sx={{ height: 600, width: "100%" }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={articles}
|
||||||
|
columns={columns}
|
||||||
|
loading={isLoading}
|
||||||
|
pageSizeOptions={[10, 25]}
|
||||||
|
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingId ? t("training.editCourse") : t("training.newTraining")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box display="flex" flexDirection="column" gap={2} mt={1}>
|
||||||
|
<TextField
|
||||||
|
label={t("common.code")}
|
||||||
|
fullWidth
|
||||||
|
value={editingId ? formData.codice : t("clients.generatedOnSave")}
|
||||||
|
disabled
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t("common.description")}
|
||||||
|
value={formData.descrizione || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, descrizione: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label={t("common.category")}
|
||||||
|
value={formData.categoriaId || trainingCategoryId || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, categoriaId: Number(e.target.value) })}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{allowedCategories.map((c: any) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.code})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t("training.validityDays")}
|
||||||
|
type="number"
|
||||||
|
value={formData.giorniValidita || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, giorniValidita: parseInt(e.target.value) || 0 })}
|
||||||
|
fullWidth
|
||||||
|
helperText="Giorni dopo i quali il corso scade"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||||
|
<Button variant="contained" onClick={handleSubmit}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/frontend/src/apps/training/routes.tsx
Normal file
18
src/frontend/src/apps/training/routes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/frontend/src/apps/training/services/trainingService.ts
Normal file
33
src/frontend/src/apps/training/services/trainingService.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import api from "../../../services/api";
|
||||||
|
import { TrainingRecord } from "../../../types";
|
||||||
|
|
||||||
|
export const trainingService = {
|
||||||
|
getAll: async (params?: { start?: string; end?: string; customerId?: number; courseId?: number }) => {
|
||||||
|
const { data } = await api.get<TrainingRecord[]>("/training", { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
create: async (record: Partial<TrainingRecord>) => {
|
||||||
|
const { data } = await api.post<TrainingRecord>("/training", record);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
update: async (id: number, record: Partial<TrainingRecord>) => {
|
||||||
|
await api.put(`/training/${id}`, record);
|
||||||
|
},
|
||||||
|
delete: async (id: number) => {
|
||||||
|
await api.delete(`/training/${id}`);
|
||||||
|
},
|
||||||
|
uploadCertificate: async (id: number, file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
await api.post(`/training/${id}/attestato`, formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getExpiring: async () => {
|
||||||
|
const { data } = await api.get<TrainingRecord[]>("/training/expiring");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
sendNotification: async (id: number) => {
|
||||||
|
await api.post(`/training/${id}/notify`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -105,6 +105,7 @@ export default function ArticleFormPage() {
|
|||||||
isSerialManaged: false,
|
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={
|
||||||
|
|||||||
305
src/frontend/src/apps/warehouse/pages/CategoriesPage.tsx
Normal file
305
src/frontend/src/apps/warehouse/pages/CategoriesPage.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Collapse,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Folder as FolderIcon,
|
||||||
|
ExpandMore as ExpandMoreIcon,
|
||||||
|
KeyboardArrowRight as KeyboardArrowRightIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useCategoryTree, useCreateCategory, useUpdateCategory, useDeleteCategory } from '../hooks';
|
||||||
|
import { CategoryTreeDto, CreateCategoryDto, UpdateCategoryDto } from '../types';
|
||||||
|
|
||||||
|
interface CategoryItemProps {
|
||||||
|
category: CategoryTreeDto;
|
||||||
|
onEdit: (category: CategoryTreeDto) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onAddSubCategory: (parentId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryItem: React.FC<CategoryItemProps> = ({ category, onEdit, onDelete, onAddSubCategory }) => {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const hasChildren = category.children && category.children.length > 0;
|
||||||
|
|
||||||
|
const handleToggle = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(!open);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
sx={{
|
||||||
|
pl: category.level * 4,
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
secondaryAction={
|
||||||
|
<Box>
|
||||||
|
<IconButton size="small" onClick={() => onAddSubCategory(category.id)} title="Aggiungi sottocategoria">
|
||||||
|
<AddIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={() => onEdit(category)} title="Modifica">
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={() => onDelete(category.id)} title="Elimina" color="error">
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: 40, cursor: hasChildren ? 'pointer' : 'default' }} onClick={(e) => hasChildren && handleToggle(e)}>
|
||||||
|
{hasChildren ? (open ? <ExpandMoreIcon /> : <KeyboardArrowRightIcon />) : <Box sx={{ width: 24 }} />}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemIcon>
|
||||||
|
<FolderIcon color={category.isActive ? 'primary' : 'disabled'} />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography variant="body1" fontWeight="medium">
|
||||||
|
{category.name}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
secondary={category.description}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
{hasChildren && (
|
||||||
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
|
<List component="div" disablePadding>
|
||||||
|
{category.children.map((child) => (
|
||||||
|
<CategoryItem
|
||||||
|
key={child.id}
|
||||||
|
category={child}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onAddSubCategory={onAddSubCategory}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CategoriesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: categories, isLoading } = useCategoryTree();
|
||||||
|
const createMutation = useCreateCategory();
|
||||||
|
const updateMutation = useUpdateCategory();
|
||||||
|
const deleteMutation = useDeleteCategory();
|
||||||
|
|
||||||
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<CategoryTreeDto | null>(null);
|
||||||
|
const [parentCategoryId, setParentCategoryId] = useState<number | undefined>(undefined);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [categoryToDelete, setCategoryToDelete] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<CreateCategoryDto>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
sortOrder: 0,
|
||||||
|
parentCategoryId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
|
||||||
|
const handleOpenDialog = (category?: CategoryTreeDto, parentId?: number) => {
|
||||||
|
if (category) {
|
||||||
|
setEditingCategory(category);
|
||||||
|
setFormData({
|
||||||
|
name: category.name,
|
||||||
|
description: category.description || '',
|
||||||
|
sortOrder: 0, // Not in TreeDto usually, default to 0
|
||||||
|
parentCategoryId: undefined, // Usually handled by structure
|
||||||
|
});
|
||||||
|
setIsActive(category.isActive);
|
||||||
|
setParentCategoryId(undefined);
|
||||||
|
} else {
|
||||||
|
setEditingCategory(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
sortOrder: 0,
|
||||||
|
parentCategoryId: parentId,
|
||||||
|
});
|
||||||
|
setIsActive(true);
|
||||||
|
setParentCategoryId(parentId);
|
||||||
|
}
|
||||||
|
setOpenDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setOpenDialog(false);
|
||||||
|
setEditingCategory(null);
|
||||||
|
setParentCategoryId(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
const updateData: UpdateCategoryDto = {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
sortOrder: formData.sortOrder,
|
||||||
|
isActive: isActive,
|
||||||
|
};
|
||||||
|
await updateMutation.mutateAsync({ id: editingCategory.id, data: updateData });
|
||||||
|
} else {
|
||||||
|
const createData: CreateCategoryDto = {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
sortOrder: formData.sortOrder,
|
||||||
|
parentCategoryId: parentCategoryId,
|
||||||
|
};
|
||||||
|
await createMutation.mutateAsync(createData);
|
||||||
|
}
|
||||||
|
handleCloseDialog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving category:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (id: number) => {
|
||||||
|
setCategoryToDelete(id);
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (categoryToDelete) {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(categoryToDelete);
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setCategoryToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting category:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Typography>{t('common.loading')}</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ mb: 3, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<Typography variant="h5" fontWeight="bold">
|
||||||
|
{t('apps.warehouse.categories.title')}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
>
|
||||||
|
{t('apps.warehouse.categories.newParams.root')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper elevation={0} variant="outlined">
|
||||||
|
<List>
|
||||||
|
{categories?.map((category) => (
|
||||||
|
<CategoryItem
|
||||||
|
key={category.id}
|
||||||
|
category={category}
|
||||||
|
onEdit={handleOpenDialog}
|
||||||
|
onDelete={handleDeleteClick}
|
||||||
|
onAddSubCategory={(parentId) => handleOpenDialog(undefined, parentId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(!categories || categories.length === 0) && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary={t('apps.warehouse.categories.empty')} />
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingCategory ? t('apps.warehouse.categories.edit') : t('apps.warehouse.categories.new')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label={t('apps.warehouse.categories.fields.name')}
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t('apps.warehouse.categories.fields.description')}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t('apps.warehouse.categories.fields.sortOrder')}
|
||||||
|
type="number"
|
||||||
|
value={formData.sortOrder}
|
||||||
|
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
{editingCategory && (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t('apps.warehouse.categories.fields.active')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={handleSubmit} variant="contained" disabled={!formData.name}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteConfirmOpen} onClose={() => setDeleteConfirmOpen(false)}>
|
||||||
|
<DialogTitle>{t('apps.warehouse.categories.deleteDialog.title')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
{t('apps.warehouse.categories.deleteDialog.content')}
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteConfirmOpen(false)}>{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={handleConfirmDelete} color="error" variant="contained">
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export { default as WarehouseDashboard } from './WarehouseDashboard';
|
|||||||
// Articles
|
// 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';
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -558,6 +558,7 @@ export const inventoryService = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Export all services
|
// Export all services
|
||||||
export default {
|
export default {
|
||||||
locations: warehouseLocationService,
|
locations: warehouseLocationService,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user