Compare commits
37 Commits
d9e1328485
...
v1.0.0-obi
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f954f494 | |||
| 99ce5e1e6a | |||
| 4810d49410 | |||
| 49abef6f96 | |||
| 64d93a936c | |||
| 0314b40f92 | |||
| c4d58f8354 | |||
| 08256f0019 | |||
| 54cf1ff276 | |||
| ad5a880219 | |||
| 9174e75be0 | |||
| dedd4f4e69 | |||
| 6d1aef3a42 | |||
| 623f7b3b56 | |||
| fef463dce5 | |||
| 20e0f6e81c | |||
| 4db05100cf | |||
| 4c72030687 | |||
| f48813c199 | |||
| 82d2680f5b | |||
| ad0ea0c7f8 | |||
| 44c0406fd2 | |||
| e70b30cab8 | |||
| 500a3197e2 | |||
| ed2472febc | |||
| 4d53c9c427 | |||
| 66160c19c5 | |||
| 436720d4a7 | |||
| 8a735e1443 | |||
| 92330b75c6 | |||
| 6666d1ddec | |||
| 4a5f426f73 | |||
| f946375e1f | |||
| 52ab4a5998 | |||
| 51a8327a8d | |||
| 6e427e0199 | |||
| a4e0c276c6 |
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`).
|
||||
@@ -2,10 +2,29 @@
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
usa ./docs/development/devlog per tenere traccia di tutti i piani di lavoro 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 ./docs/development/devlog per tenere traccia di tutti i piani di lavoro 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.md riassuntivo con link ai file specifici dentro ./docs/development/devlog e una breve sintesi specificando che tipo di sviluppo si è concluso o si sta lavorando.
|
||||
|
||||
usa ./src/backend per tutto quello che riguarda il backend in .NET
|
||||
|
||||
usa ./src/frontend per tutto quello che riguarda il frontend in react
|
||||
usa ./src/frontend per tutto quello che riguarda il frontend in react
|
||||
|
||||
## Struttura Modulare del Progetto
|
||||
|
||||
Il progetto segue una rigorosa struttura modulare sia per il backend che per il frontend. Ogni nuova funzionalità o dominio di business deve essere incapsulato nel proprio modulo.
|
||||
|
||||
### Backend (.NET)
|
||||
- **API Controllers**: `src/backend/Zentral.API/Modules/[NomeModulo]/Controllers/`
|
||||
- I controller devono avere il namespace `Zentral.API.Modules.[NomeModulo].Controllers`.
|
||||
- Le rotte devono seguire il pattern `api/[nome-modulo]/[controller]`.
|
||||
- **Entities**: `src/backend/Zentral.Domain/Entities/[NomeModulo]/`
|
||||
- Le entità devono avere il namespace `Zentral.Domain.Entities.[NomeModulo]`.
|
||||
|
||||
### Frontend (React)
|
||||
- **Moduli**: `src/frontend/src/modules/[nome-modulo]/`
|
||||
- **Pagine**: `src/frontend/src/modules/[nome-modulo]/pages/`
|
||||
- **Componenti**: `src/frontend/src/modules/[nome-modulo]/components/`
|
||||
- **Rotte**: `src/frontend/src/modules/[nome-modulo]/routes.tsx`
|
||||
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||
@@ -2,6 +2,8 @@
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Produci sempre prima il piano di implementazione nelle cartelle dedicate e proponi di default all'utente di visionarlo, se l'utente specifica di voler andare avanti, prosegui con l'implementazione del piano senza fermarti; aggiorna il piano man mano che viene sviluppato.
|
||||
|
||||
Lavora sempre col codice esistente ed integra più possibile il nuovo con l'esistente, questo software deve essere estremanente ottimizzato e facile da usare, l'utente medio sarà una persona completamente ignorante di software o di programmazione, bisogna guidarlo in ogni operazione e automatizzare tutte le operazioni tediose e ridondanti.
|
||||
|
||||
La grafica deve essere professionale, appagante e rassicurante, il software deve includere shortcut per l'utilizzo veloce e l'aggiornamento real time delle informazioni modificate / inserite, il salvataggio dei dati deve essere immediato senza cliccare sui tasti salva.
|
||||
@@ -25,3 +27,5 @@ Il gestionale è multilingua e gestisce la funzione i18n, per ora le lingue gest
|
||||
Tutta la parte database deve essere gestita sempre in code first, non voglio vedere query SQL RAW da nessuna parte e tutto il database deve sempre essere gestito a migrazioni con ef migration, come database si deve usare sqlite per lo sviluppo e mysql per la produzione.
|
||||
|
||||
Prima ancora di pianificare la nuova attività, però, dobbiamo sempre verificare che attualmente l'applicazione funzioni quindi va avviata e testata preliminarmente nelle sue funzioni di base (e tenuta avviata sempre finchè si sviluppa).
|
||||
|
||||
Se il backend restituisce un errore specifico, questo deve essere chiaramente notificato all'utente, invece di un generico "Errore".
|
||||
@@ -3,12 +3,17 @@ trigger: always_on
|
||||
---
|
||||
|
||||
Il software si chiama Zentral e, tramite diverse applicazioni, si occupa di gestire
|
||||
- acquisti
|
||||
- produzione
|
||||
- vendite
|
||||
- eventi
|
||||
- clienti
|
||||
- magazzino
|
||||
- acquisti (Gestione ordini fornitori, DDT in entrata, fatture passive e analisi acquisti)
|
||||
- produzione (Cicli produttivi, distinte base, pianificazione MRP e controllo avanzamento)
|
||||
- vendite (Gestione ordini clienti, DDT in uscita, fatture attive e analisi vendite)
|
||||
- eventi (Gestione eventi, pianificazione e controllo avanzamento)
|
||||
- clienti (Gestione clienti, fatture, contratti e analisi clienti)
|
||||
- qualità (Controlli qualità, gestione non conformità, certificazioni e audit)
|
||||
- magazzino (Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte)
|
||||
- HR (o personale) (Gestione personale, contratti, pagamenti, assenze, rimborsi e analisi personale)
|
||||
- report e stampe (Gestione report, creazione e analisi report)
|
||||
- comunicazioni (Gestione invio mail, chat interna, condivisione risorse del gestionale ad interni ed esterni)
|
||||
- corsi e formazione (Gestione corsi di formazione, erogazione corsi, tracciabilità scadenze)
|
||||
|
||||
mostra statistiche grafiche per ogni applicazione nella dashboard dell'applicazione.
|
||||
|
||||
|
||||
@@ -6,7 +6,56 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
||||
|
||||
- [2025-12-02 Rebranding Apollinare to Zentral](./log/2025-12-02_rebranding.md) - **Completato**
|
||||
- Rinomina completa del progetto (Backend & Frontend).
|
||||
- [2025-12-13 Mandatory Training Specs](./devlog/2025-12-13-164500_mandatory_training_specs.md) - **Completato**
|
||||
- Definizione specifiche funzionali e Implementazione modulo (Backend + Frontend).
|
||||
- [Log Implementazione](./devlog/2025-12-13-170000_mandatory_training_implementation.md)
|
||||
- [2025-12-03 UI Restructuring](./devlog/2025-12-03_ui_restructuring.md) - **Completato**
|
||||
- Ristrutturazione interfaccia: Sidebar a 2 livelli, Tabs, SearchBar.
|
||||
- [2025-12-03 Backend Fix](./devlog/2025-12-03_backend_fix.md) - **Completato**
|
||||
- Fix mancata migrazione database e avvio backend.
|
||||
- [2025-12-03 Module Management Refinement](./devlog/2025-12-03_module_management.md) - **Completato**
|
||||
- Refinement logica attivazione/acquisto moduli, gestione dipendenze e UI.
|
||||
- [2025-12-03 Move Reports Menu](./devlog/2025-12-03_move_reports_menu.md) - **Completato**
|
||||
- Spostamento voce menu Report sotto Amministrazione.
|
||||
- [2025-12-03 Report Designer Theme](./devlog/2025-12-03_report_designer_theme.md) - **Completato**
|
||||
- Allineamento completo del Report Designer al tema scuro (Canvas, Toolbar, Dialogs).
|
||||
- [2025-12-04 Event Management Module](./devlog/2025-12-04_event_management_plan.md) - **Completato**
|
||||
- Implementazione modulo Gestione Eventi: strutturazione frontend, integrazione funzionalità e attivazione store.
|
||||
- [Event Module Development](./devlog/event-module.md) - Implementazione modulo eventi
|
||||
- [Menu Refactoring](./devlog/menu-refactoring.md) - Riorganizzazione menu e moduli (Dashboard, Clienti, Articoli, Risorse)
|
||||
- [2025-12-03 Implementazione Modulo Personale](./devlog/2025-12-03_implementazione_modulo_personale.md) - **Completato**
|
||||
- Implementazione entità, API e Frontend per gestione Personale (Dipendenti, Contratti, Assenze, Pagamenti).
|
||||
- [2025-12-04 Zentral Dashboard and Menu Cleanup](./devlog/2025-12-04-023000_zentral_dashboard.md) - **Completato**
|
||||
- Pulizia menu Zentral (rimozione voci ridondanti) e creazione nuova Dashboard principale con riepilogo moduli attivi.
|
||||
- [2025-12-04 Dashboard Widgets](./devlog/2025-12-04-030000_dashboard_widgets.md) - **Completato**
|
||||
- Implementazione sistema widget personalizzabili (drag & drop), salvataggio preferenze utente, widget "Active Modules" e "Warehouse Stats".
|
||||
- [2025-12-04 Report Designer Module](./devlog/2025-12-04-215121_report_designer_module.md) - **Completato**
|
||||
- Refactoring Report Designer in modulo autonomo e abilitazione stampa PDF condizionale.
|
||||
- [2025-12-04 Fix Report Designer Imports](./devlog/2025-12-04-212500_fix_report_designer_imports.md) - **Completato**
|
||||
- Correzione import path nel modulo Report Designer e registrazione modulo nel backend.
|
||||
- [2025-12-05 Rename Modules to Apps](./devlog/2025-12-05-194100_rename_modules_to_apps.md) - **Completato**
|
||||
- Rinomina terminologia "Modulo" in "Applicazione" (App) su Backend e Frontend.
|
||||
- [2025-12-05 Remove Warehouse Tabs](./devlog/2025-12-05-224000_remove_warehouse_tabs.md) - **Completato**
|
||||
- Rimozione tab interne e header dal modulo Magazzino per uniformità con la UI principale.
|
||||
- [2025-12-05 Live Data Alignment](./devlog/2025-12-05-230000_live_data_alignment.md) - **Completato**
|
||||
- Implementazione `SchemaDiscoveryService` per allineamento automatico dataset report con strutture dati live.
|
||||
- [2025-12-06 Sidebar Collapsible](./devlog/2025-12-06-010500_sidebar_collapsible.md) - **Completato**
|
||||
- Reso il menu laterale collassabile (manuale e responsive) con visualizzazione a sole icone.
|
||||
- [2025-12-06 Tab UX Improvements](./devlog/2025-12-06-011000_tab_ux_improvements.md) - **Completato**
|
||||
- Miglioramento UX tab: chiusura con middle-click, drag & drop, gruppi di tab personalizzati.
|
||||
- [2025-12-06 Tab Flicker Fix](./devlog/2025-12-06-011500_tab_flicker_fix.md) - **Completato**
|
||||
- Risolto problema di flicker rimuovendo l'aggiornamento manuale dello stato attivo e affidandosi esclusivamente alla sincronizzazione con l'URL.
|
||||
- [2025-12-06 02:10:00 - Fix Traduzione Tab](./devlog/2025-12-06-021000_fix_tab_translation.md) - **Completato**
|
||||
- [2025-12-06 01:55:00 - Traduzione Menu, Search Bar e Tab](./devlog/2025-12-06-015500_translate_navigation.md) - **Completato**
|
||||
- [2025-12-06 01:48:00 - Traduzione Modulo Acquisti](./devlog/2025-12-06-014800_translate_purchases.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.
|
||||
- [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato**
|
||||
- [2025-12-12 Training Course Module](./devlog/2025-12-12-105500_training_course_module.md) - **Completato**
|
||||
- Implementazione gestione Corsi (sottocategorie Formazione), Registro Training, Scadenze, Notifiche e Dashboard.
|
||||
- [2025-12-12 Communications Module](./devlog/2025-12-12-110000_communications_module.md) - **Completato**
|
||||
- [2025-12-12 Resend Integration](./devlog/2025-12-12-120000_resend_integration.md) - **Completato**
|
||||
- [2025-12-12 Magazzino: Categorie Gerarchiche](./devlog/2025-12-12-133000_remove_product_groups_add_categories.md) - **Completato**
|
||||
- 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.
|
||||
36
docs/development/devlog/2025-12-03_event_management_plan.md
Normal file
36
docs/development/devlog/2025-12-03_event_management_plan.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Piano di Sviluppo Modulo Gestione Eventi
|
||||
|
||||
## Obiettivo
|
||||
Implementare il modulo "Gestione Eventi" integrando le funzionalità esistenti (Dashboard, Calendario, Eventi, Clienti, Location, Articoli, Risorse) e rendendolo acquistabile nello store.
|
||||
|
||||
## Stato Attuale
|
||||
- Esistono già pagine frontend: `EventiPage`, `EventoDetailPage`, `CalendarioPage`, `ClientiPage`, `LocationPage`, `ArticoliPage`, `RisorsePage`.
|
||||
- Esiste già backend: `EventiController`, `Evento` entity, e relative entità collegate.
|
||||
- Il modulo non è ancora strutturato come "AppModule" formale nel sistema (non c'è cartella `modules/events` nel frontend, non c'è guard).
|
||||
|
||||
## Piano di Lavoro
|
||||
|
||||
### 1. Strutturazione Modulo Frontend
|
||||
- [x] Creare cartella `src/frontend/src/modules/events`.
|
||||
- [x] Spostare le pagine specifiche degli eventi (`EventiPage`, `EventoDetailPage`, `CalendarioPage`, `LocationPage`) dentro `src/frontend/src/modules/events/pages`.
|
||||
- [x] Creare `src/frontend/src/modules/events/routes.tsx` per gestire le rotte del modulo.
|
||||
- [x] Aggiornare `App.tsx` per includere le rotte del modulo eventi sotto `ModuleGuard`.
|
||||
|
||||
### 2. Integrazione Funzionalità Esistenti
|
||||
- [x] Verificare che `ClientiPage`, `ArticoliPage`, `RisorsePage` siano accessibili e integrate correttamente. (Sono rimaste nel menu Core come previsto).
|
||||
- [x] Assicurarsi che il menu laterale mostri le voci corrette quando il modulo è attivo.
|
||||
|
||||
### 3. Backend & Database
|
||||
- [x] Verificare l'esistenza del modulo "events" nella tabella `AppModules` (o crearlo tramite migrazione/seed).
|
||||
- [x] Assicurarsi che le API esistenti siano protette o accessibili correttamente.
|
||||
|
||||
### 4. Store & Attivazione
|
||||
- [x] Verificare che il modulo appaia nella pagina di acquisto moduli.
|
||||
- [x] Testare l'attivazione/disattivazione del modulo.
|
||||
|
||||
### 5. Refactoring & Pulizia
|
||||
- [x] Aggiornare gli import nei file spostati.
|
||||
- [x] Rimuovere le rotte vecchie da `App.tsx`.
|
||||
|
||||
## Note
|
||||
- I moduli `Clienti`, `Articoli`, `Risorse` sembrano essere entità "core" o condivise. Per ora le manterremo accessibili, valutando se spostarle in moduli specifici (es. `crm`, `catalog`, `resources`) in futuro, o se lasciarle globali. Dato che la richiesta è specifica sugli eventi, ci concentreremo sul raggruppare le funzioni eventi.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Implementazione Modulo Personale
|
||||
|
||||
## Obiettivo
|
||||
Implementare il modulo "Personale" per la gestione delle risorse umane, come richiesto nelle specifiche di posizionamento di mercato.
|
||||
|
||||
## Funzionalità Richieste
|
||||
- **Gestione Personale (Dipendenti)**: Anagrafica dipendenti.
|
||||
- **Contratti**: Gestione dei contratti di lavoro (tipo, date, livello, retribuzione).
|
||||
- **Assenze**: Tracciamento ferie, malattie, permessi.
|
||||
- **Pagamenti**: Registro dei pagamenti stipendi.
|
||||
- **Rimborsi**: Gestione note spese e rimborsi.
|
||||
- **Analisi**: Dashboard statistiche (da implementare successivamente).
|
||||
|
||||
## Piano di Lavoro
|
||||
|
||||
### Backend (.NET)
|
||||
1. [ ] Creare cartella `src/backend/Zentral.Domain/Entities/Personale`.
|
||||
2. [ ] Definire le entità:
|
||||
* `Dipendente`
|
||||
* `Contratto`
|
||||
* `Assenza`
|
||||
* `Pagamento`
|
||||
* `Rimborso`
|
||||
3. [ ] Aggiornare `ZentralDbContext` aggiungendo i `DbSet`.
|
||||
4. [ ] Creare la migrazione EF Core.
|
||||
5. [ ] Creare i Controller API in `src/backend/Zentral.API/Controllers/Personale`.
|
||||
|
||||
### Frontend (React)
|
||||
1. [ ] Strutturare `src/frontend/src/modules/personale`.
|
||||
2. [ ] Implementare le pagine CRUD:
|
||||
* `DipendentiPage`
|
||||
* `ContrattiPage`
|
||||
* `AssenzePage`
|
||||
* `PagamentiPage` (include Rimborsi per ora o separato).
|
||||
3. [ ] Configurare il routing del modulo.
|
||||
4. [ ] Aggiungere il modulo alla configurazione `AppModule` (se non presente) e verificare l'attivazione.
|
||||
|
||||
### Integrazione
|
||||
1. [ ] Verificare che il modulo appaia nel menu solo se attivo.
|
||||
2. [ ] Testare il flusso completo (creazione dipendente -> contratto -> assenza).
|
||||
25
docs/development/devlog/2025-12-03_menu_refactoring.md
Normal file
25
docs/development/devlog/2025-12-03_menu_refactoring.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Refactoring Menu and Modules
|
||||
|
||||
## Objective
|
||||
Restructure the navigation menu and organize existing functionalities into specific modules as requested:
|
||||
- Dashboard -> Gestione Eventi
|
||||
- Clienti -> Vendite
|
||||
- Articoli -> Magazzino
|
||||
- Risorse -> Personale (New Module)
|
||||
|
||||
## Plan
|
||||
1. **Create "Personale" module**: Create directory structure `src/frontend/src/modules/personale`.
|
||||
2. **Move Files**:
|
||||
- `src/frontend/src/pages/Dashboard.tsx` -> `src/frontend/src/modules/events/pages/DashboardPage.tsx`
|
||||
- `src/frontend/src/pages/ClientiPage.tsx` -> `src/frontend/src/modules/sales/pages/ClientiPage.tsx`
|
||||
- `src/frontend/src/pages/ArticoliPage.tsx` -> `src/frontend/src/modules/warehouse/pages/ArticoliPage.tsx`
|
||||
- `src/frontend/src/pages/RisorsePage.tsx` -> `src/frontend/src/modules/personale/pages/RisorsePage.tsx`
|
||||
3. **Update Routes**:
|
||||
- Update `src/frontend/src/App.tsx` to remove old routes and add `PersonaleRoutes`.
|
||||
- Update `src/frontend/src/modules/events/routes.tsx` to include Dashboard.
|
||||
- Update `src/frontend/src/modules/sales/routes.tsx` to include Clienti.
|
||||
- Update `src/frontend/src/modules/warehouse/routes.tsx` to include Articoli.
|
||||
- Create `src/frontend/src/modules/personale/routes.tsx`.
|
||||
4. **Update Sidebar**:
|
||||
- Update `src/frontend/src/components/Sidebar.tsx` to reflect the new menu structure.
|
||||
5. **Verify**: Ensure all links work and the application runs correctly.
|
||||
36
docs/development/devlog/2025-12-03_module_management.md
Normal file
36
docs/development/devlog/2025-12-03_module_management.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Refinement Gestione Moduli
|
||||
|
||||
**Data:** 2025-12-03
|
||||
**Stato:** Completato
|
||||
|
||||
## Obiettivo
|
||||
Raffinare il flusso di attivazione e acquisto dei moduli, separando chiaramente lo stato di "acquisto" (subscription valida) dallo stato di "attivazione" (abilitazione utente). Implementare la gestione delle dipendenze sia in fase di acquisto (bulk purchase) che di disattivazione.
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### Backend (.NET)
|
||||
- **Entità `ModuleSubscription`**: Modificato il metodo `IsValid()` per controllare *solo* la validità temporale della subscription (date), ignorando il flag `IsEnabled`.
|
||||
- **Servizio `ModuleService`**:
|
||||
- Aggiornato `IsModuleEnabledAsync` per verificare sia `IsEnabled` (utente) che `IsValid` (date).
|
||||
- Aggiornato `DisableModuleAsync` per permettere la disattivazione di un modulo se i suoi dipendenti sono già stati disattivati dall'utente (anche se validi).
|
||||
- Aggiornato `GetActiveModulesAsync` per filtrare i moduli visibili nel menu solo se sono sia validi che abilitati dall'utente.
|
||||
- **Controller `ModulesController`**: Aggiornato il mapping DTO per riflettere correttamente lo stato `IsEnabled` verso il frontend.
|
||||
|
||||
### Frontend (React)
|
||||
- **Nuovo Componente `ModulePurchaseDialog`**:
|
||||
- Gestisce l'acquisto dei moduli.
|
||||
- **Dependency Resolution**: Calcola ricorsivamente le dipendenze mancanti e le include nel totale dell'acquisto.
|
||||
- Mostra un riepilogo chiaro con i costi aggiuntivi per le dipendenze.
|
||||
- **Pagina `ModulesAdminPage`**:
|
||||
- Aggiornata la UI delle card per mostrare il tasto "Acquista" solo se la subscription è scaduta/inesistente.
|
||||
- Se il modulo è valido ma disattivato, mostra lo stato "Disattivato" e permette la riattivazione tramite toggle.
|
||||
- Implementata gestione errori avanzata: mostra messaggi specifici dal backend (es. dipendenze attive che impediscono la disattivazione).
|
||||
- **Sidebar**: Verifica che i moduli disattivati non compaiano nel menu laterale.
|
||||
|
||||
## Verifica
|
||||
- [x] Il tasto "Acquista" appare solo per moduli non posseduti o scaduti.
|
||||
- [x] Disattivare un modulo valido lo mantiene "Disattivato" ma non richiede nuovo acquisto.
|
||||
- [x] Disattivare un modulo con dipendenze attive mostra un errore specifico.
|
||||
- [x] Disattivare un modulo con dipendenze disattivate (ma valide) è permesso.
|
||||
- [x] I moduli disattivati scompaiono dal menu laterale.
|
||||
- [x] L'acquisto di un modulo include automaticamente le dipendenze mancanti nel prezzo e nell'attivazione.
|
||||
19
docs/development/devlog/2025-12-03_move_reports_menu.md
Normal file
19
docs/development/devlog/2025-12-03_move_reports_menu.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Spostamento Menu Report
|
||||
|
||||
**Data:** 2025-12-03
|
||||
**Stato:** Completato
|
||||
|
||||
## Obiettivo
|
||||
Spostare la voce di menu "Report" dalla sezione principale "Zentral" alla sezione "Amministrazione", in quanto la gestione dei template di stampa è un compito amministrativo.
|
||||
|
||||
## Modifiche Apportate
|
||||
### Frontend
|
||||
- Modificato `src/frontend/src/components/Sidebar.tsx`:
|
||||
- Rimossa la voce `reports` dalla lista `children` della sezione `core`.
|
||||
- Aggiunta la voce `reports` alla lista `children` della sezione `admin`.
|
||||
|
||||
## Verifica
|
||||
- Avviata l'applicazione (`make run dev`).
|
||||
- Verificato tramite browser che la voce "Report" non sia più presente sotto "Zentral".
|
||||
- Verificato tramite browser che la voce "Report" sia presente sotto "Amministrazione".
|
||||
- Verificato che il routing verso `/report-templates` funzioni ancora correttamente.
|
||||
34
docs/development/devlog/2025-12-03_report_designer_theme.md
Normal file
34
docs/development/devlog/2025-12-03_report_designer_theme.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Report Designer Theme Alignment
|
||||
|
||||
## Obiettivo
|
||||
Allineare completamente il Report Designer al tema scuro dell'applicazione. L'obiettivo è eliminare incongruenze visive come sfondi bianchi hardcoded, griglie poco visibili e componenti non adattivi in modalità dark.
|
||||
|
||||
## Stato Attuale
|
||||
**Completato**
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### 1. Editor Canvas (`EditorCanvas.tsx`)
|
||||
- **Sfondo Canvas**: Implementata logica dinamica per utilizzare `#1e1e1e` (dark) o `#ffffff` (light) in base al tema.
|
||||
- **Griglia**: Colore delle linee della griglia reso dinamico (`#333333` in dark mode) per garantire visibilità.
|
||||
- **Shadows & Selection**: Aggiornati colori di selezione e ombre per essere coerenti con il tema.
|
||||
|
||||
### 2. Toolbar (`EditorToolbar.tsx`)
|
||||
- **Background Pulsanti**: Sostituiti i colori hardcoded `grey.100` e `grey.200` con i token del tema `action.hover` e `action.selected`.
|
||||
- **Visibilità**: Migliorato il contrasto delle icone e dei testi nella toolbar.
|
||||
|
||||
### 3. Designer Page (`ReportEditorPage.tsx`)
|
||||
- **Sfondo Workspace**: Lo sfondo del contenitore principale (dietro il foglio) ora utilizza `theme.palette.background.default` in dark mode invece di un grigio scuro hardcoded, uniformandosi al resto dell'app.
|
||||
|
||||
### 4. Pannelli Laterali (`DataBindingPanel.tsx`, `DatasetSelector.tsx`)
|
||||
- **Empty States**: Sostituiti sfondi `grey.50` con `background.default` per le schermate di "Nessun dataset selezionato".
|
||||
- **Liste e Header**: Aggiornati i colori di sfondo degli header e degli elementi delle liste per utilizzare `action.hover` e colori primari con opacità corretta.
|
||||
|
||||
### 5. Dialogs (`PreviewDialog.tsx`, `ImageUploadDialog.tsx`)
|
||||
- **Preview**: Sfondi delle liste e delle sezioni di dettaglio aggiornati a `background.default`.
|
||||
- **Image Upload**: Corretti sfondi dell'area di drag & drop e dei pannelli per supportare il dark mode.
|
||||
- **Fix Tecnici**: Aggiunta importazione mancante `alpha` in `ImageUploadDialog.tsx`.
|
||||
|
||||
## Prossimi Passi Suggeriti
|
||||
- Verificare eventuali altri dialoghi minori nel report editor (es. impostazioni avanzate) per assicurare copertura totale.
|
||||
- Testare l'export PDF per assicurarsi che i colori di sfondo (se non desiderati) non vengano esportati erroneamente (il canvas background è solo visuale).
|
||||
@@ -0,0 +1,25 @@
|
||||
# Zentral Dashboard and Menu Cleanup
|
||||
|
||||
## Stato Attuale
|
||||
Completato.
|
||||
|
||||
## Lavoro Svolto
|
||||
1. **Pulizia Menu Zentral**:
|
||||
- Verificato che le voci "Clienti", "Articoli" e "Risorse" nel menu "Zentral" erano ridondanti o non funzionanti.
|
||||
- "Articoli" è gestito dal modulo Warehouse (`/warehouse/articles`).
|
||||
- "Clienti" e "Risorse" erano link non funzionanti (`/clienti`, `/risorse` non definiti nelle rotte).
|
||||
- Rimossi questi elementi dal menu laterale (`Sidebar.tsx`).
|
||||
- Appiattito il menu "Zentral" in un'unica voce di primo livello "Zentral Dashboard" che punta direttamente alla home page.
|
||||
|
||||
2. **Nuova Zentral Dashboard**:
|
||||
- Aggiornato `src/frontend/src/pages/Dashboard.tsx` per diventare la nuova homepage "Zentral Dashboard".
|
||||
- La dashboard ora mostra:
|
||||
- Un messaggio di benvenuto con il conteggio dei moduli attivi.
|
||||
- Una griglia di card per ogni modulo attivo, con icona, nome, descrizione e pulsante per aprire l'applicazione.
|
||||
- Gestione dello stato di caricamento e caso di nessun modulo attivo.
|
||||
- La dashboard utilizza `useModules` per recuperare dinamicamente i moduli attivi.
|
||||
- Integrata con il sistema di Tab (`openTab`) per aprire le applicazioni.
|
||||
|
||||
## Prossimi Passi Suggeriti
|
||||
- Implementare endpoint di backend per recuperare statistiche globali reali (es. numero ordini aperti, valore magazzino, ecc.) da mostrare nella dashboard principale.
|
||||
- Aggiungere widget personalizzabili nella dashboard.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Zentral Dashboard Widgets
|
||||
|
||||
## Stato Attuale
|
||||
Completato.
|
||||
|
||||
## Obiettivo
|
||||
Implementare un sistema di widget per la dashboard di Zentral.
|
||||
- I moduli devono poter esporre widget.
|
||||
- Gli widget sono visibili solo se il modulo è attivo.
|
||||
- La dashboard deve essere personalizzabile (drag & drop, resize) tramite `react-grid-layout`.
|
||||
- La configurazione della dashboard deve essere salvata per ogni utente.
|
||||
|
||||
## Lavoro Svolto
|
||||
1. **Backend**:
|
||||
- Creata entità `UserDashboardPreference` per salvare il layout JSON della dashboard per ogni utente.
|
||||
- Creato `DashboardController` per salvare/caricare la configurazione.
|
||||
- Aggiornato `ZentralDbContext` e creata migrazione `AddUserDashboardPreference`.
|
||||
- *Nota*: Il salvataggio su backend è attualmente disabilitato in favore del `localStorage` su richiesta utente, in attesa del sistema di gestione utenti completo.
|
||||
|
||||
2. **Frontend - Setup**:
|
||||
- Installato `react-grid-layout`.
|
||||
- Creato `WidgetRegistry` (`src/frontend/src/services/WidgetRegistry.ts`) per gestire i widget disponibili.
|
||||
- Definita interfaccia `WidgetDefinition`.
|
||||
- Creata funzione di registrazione `registerWidgets` chiamata in `main.tsx`.
|
||||
|
||||
3. **Frontend - Implementazione Widget**:
|
||||
- Creato `ActiveModulesWidget`: lista moduli attivi (sostituisce la vecchia dashboard statica).
|
||||
- Creato `WelcomeWidget`: banner di benvenuto.
|
||||
- Creati widget statistici per tutti i moduli:
|
||||
- `WarehouseStatsWidget`
|
||||
- `SalesStatsWidget`
|
||||
- `PurchasesStatsWidget`
|
||||
- `ProductionStatsWidget`
|
||||
- `HRStatsWidget`
|
||||
- `EventsStatsWidget`
|
||||
|
||||
4. **Frontend - Dashboard**:
|
||||
- Aggiornato `Dashboard.tsx` per usare `ResponsiveGridLayout`.
|
||||
- Implementata modalità "Edit" per aggiungere/rimuovere/spostare widget.
|
||||
- Implementato salvataggio della configurazione su `localStorage` (browser).
|
||||
- Implementato caricamento configurazione con fallback a layout di default (Welcome + Active Modules).
|
||||
- **Fix Sovrapposizione e Griglia Rigida**:
|
||||
- Disabilitato ridimensionamento widget (`isResizable={false}`).
|
||||
- Impostato `compactType={null}` per disabilitare il riposizionamento automatico (galleggiamento) e permettere posizionamento libero.
|
||||
- Impostato `preventCollision={true}` per impedire sovrapposizioni e spostamenti indesiderati durante il trascinamento.
|
||||
|
||||
5. **Testing**:
|
||||
- Attivati tutti i moduli tramite API.
|
||||
- Verificato caricamento dashboard con tutti i moduli attivi.
|
||||
- Verificato funzionamento modalità Edit:
|
||||
- Ridimensionamento disabilitato.
|
||||
- Spostamento widget in spazi vuoti funzionante.
|
||||
- Tentativo di sovrapposizione bloccato (collisione prevenuta).
|
||||
- Salvataggio layout persistente.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Fix Report Designer Imports and Activation
|
||||
|
||||
## Problema
|
||||
Il modulo Report Designer non si caricava a causa di percorsi di importazione errati nei componenti frontend e mancava la registrazione del modulo nel backend.
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### Frontend
|
||||
Corretti i percorsi di importazione in:
|
||||
- `DatasetManagerDialog.tsx`
|
||||
- `PreviewDialog.tsx`
|
||||
- `OutputFieldsEditor.tsx`
|
||||
- `FilterBuilder.tsx`
|
||||
- `RelationshipEditor.tsx`
|
||||
- `PropertiesPanel.tsx`
|
||||
- `PageNavigator.tsx`
|
||||
- `ContextMenu.tsx`
|
||||
- `EditorCanvas.tsx`
|
||||
- `DataBindingPanel.tsx`
|
||||
- `DatasetSelector.tsx`
|
||||
- `EditorToolbar.tsx`
|
||||
|
||||
I percorsi `../../services/reportService` e `../../types/report` sono stati aggiornati a `../../../../services/reportService` e `../../../../types/report`.
|
||||
|
||||
### Backend
|
||||
- Aggiornato `ModuleService.cs` per includere il modulo `report-designer` nel metodo `SeedDefaultModulesAsync`.
|
||||
- Riavviato il backend per applicare il seeding del nuovo modulo.
|
||||
|
||||
## Verifica
|
||||
- Attivato il modulo `report-designer` tramite l'interfaccia `/modules`.
|
||||
- Verificato il caricamento corretto della pagina `/report-designer`.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Refactoring Report Designer into a Module
|
||||
|
||||
## Obiettivo
|
||||
Trasformare la parte del report designer in un modulo a sé stante (`report-designer`).
|
||||
Una volta attivato, questo modulo abilita nelle altre applicazioni la possibilità di stampare PDF.
|
||||
|
||||
## Stato Attuale
|
||||
Il codice del report designer è sparso in `src/frontend/src/pages` e `src/backend/Zentral.API/Controllers`.
|
||||
|
||||
## Piano di Lavoro
|
||||
1. **Frontend**:
|
||||
- [x] Creare struttura modulo: `src/frontend/src/modules/report-designer/`
|
||||
- [x] Spostare pagine e componenti.
|
||||
- [x] Creare `routes.tsx`.
|
||||
- [x] Aggiornare i riferimenti e le rotte in `App.tsx`.
|
||||
- [x] Aggiornare `reportService.ts` con le nuove rotte API.
|
||||
2. **Backend**:
|
||||
- [x] Creare struttura modulo: `src/backend/Zentral.API/Modules/ReportDesigner/`
|
||||
- [x] Spostare Controller (`ReportTemplatesController`, `ReportResourcesController`, `ReportsController`).
|
||||
- [x] Aggiornare namespace e rotte API.
|
||||
- [x] Spostare DTO condivisi in `AprtModels.cs` per risolvere dipendenze circolari/mancanti.
|
||||
3. **Integrazione**:
|
||||
- [x] Verificare build Frontend e Backend.
|
||||
|
||||
## Log
|
||||
- 2025-12-04: Iniziato refactoring.
|
||||
- 2025-12-04: Spostati file frontend e creati routes.
|
||||
- 2025-12-04: Aggiornato App.tsx con ModuleGuard.
|
||||
- 2025-12-04: Spostati controller backend e aggiornati namespace.
|
||||
- 2025-12-04: Risolti problemi di compilazione backend spostando DTO.
|
||||
- 2025-12-04: Aggiornato reportService.ts frontend.
|
||||
- 2025-12-04: Completato.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Rename Modules to Apps
|
||||
|
||||
**Stato:** Completato
|
||||
**Data:** 2025-12-05
|
||||
|
||||
## Descrizione
|
||||
Rinomina completa della terminologia "Modulo" (Module) in "Applicazione" (App) in tutto il progetto (Backend, Frontend, Database).
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### Backend
|
||||
- Rinominate entità `AppModule` -> `App`, `ModuleSubscription` -> `AppSubscription`.
|
||||
- Rinominate tabelle database `AppModules` -> `Apps`, `ModuleSubscriptions` -> `AppSubscriptions`.
|
||||
- Rinominati servizi `ModuleService` -> `AppService`.
|
||||
- Rinominati controller `ModulesController` -> `AppsController`.
|
||||
- Aggiornati namespace da `Zentral.API.Modules` a `Zentral.API.Apps`.
|
||||
- Aggiornate rotte API da `api/modules` a `api/apps`.
|
||||
- Creata e applicata migrazione `RenameModulesToApps`.
|
||||
|
||||
### Frontend
|
||||
- Rinominate directory `src/frontend/src/modules` -> `src/frontend/src/apps`.
|
||||
- Rinominati file e componenti principali (es. `ModuleContext` -> `AppContext`, `ModulesAdminPage` -> `AppsAdminPage`).
|
||||
- Aggiornati tutti i riferimenti nel codice (variabili, interfacce, hook).
|
||||
- Aggiornati i file di traduzione (i18n) per usare "Applicazione" invece di "Modulo".
|
||||
|
||||
### Documentazione
|
||||
- Aggiornato `docs/development/development-folders.md` con la nuova struttura.
|
||||
- Aggiornato `docs/development/ZENTRAL.md`.
|
||||
|
||||
## Note
|
||||
- La build del frontend è passata con successo.
|
||||
- Il backend è stato aggiornato e la migrazione applicata.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Rimozione Tab Magazzino
|
||||
|
||||
## Obiettivo
|
||||
Rimuovere le tab di navigazione interne al modulo Magazzino (`WarehouseLayout`), in quanto ridondanti rispetto alle tab principali dell'applicazione.
|
||||
|
||||
## Modifiche Apportate
|
||||
- Modificato `src/frontend/src/apps/warehouse/components/WarehouseLayout.tsx`:
|
||||
- Rimossa la componente `Tabs` e la logica associata (`navItems`, `useState`, `useEffect`).
|
||||
- Rimosso l'header contenente il titolo "Gestione Magazzino" e i breadcrumbs.
|
||||
- Semplificato il layout per mostrare solo l'`Outlet` all'interno di un `Box`.
|
||||
- Aggiunto padding (`p: 3`) al contenitore del contenuto per garantire una spaziatura adeguata.
|
||||
|
||||
## Stato
|
||||
Completato.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Live Data Alignment for Report Designer
|
||||
|
||||
## Obiettivo
|
||||
Garantire che i dataset utilizzati nel report designer siano sempre automaticamente allineati con le strutture dati vive del gestionale, leggendo le strutture live invece di affidarsi a dati pre-configurati.
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### Backend
|
||||
1. **Nuovo Servizio `SchemaDiscoveryService`**:
|
||||
* Creato un servizio che scansiona `ZentralDbContext` per trovare tutti i `DbSet` disponibili.
|
||||
* Genera dinamicamente gli schemi dei dati basandosi sulle proprietà delle entità.
|
||||
* Supporta il caricamento dinamico delle entità con eager loading delle proprietà di navigazione.
|
||||
* Include un dizionario di metadati per mantenere descrizioni e icone curate per i dataset principali (Evento, Cliente, ecc.), pur supportando nuovi dataset automaticamente.
|
||||
|
||||
2. **Refactoring `ReportsController`**:
|
||||
* Rimossi i metodi statici hardcoded per la generazione degli schemi (`GetEventoSchema`, ecc.).
|
||||
* Rimossa la lista hardcoded dei dataset disponibili.
|
||||
* Integrato `SchemaDiscoveryService` per ottenere la lista dei dataset, gli schemi e i dati.
|
||||
* Aggiornato `GetVirtualDatasetEntities` per usare il servizio di discovery.
|
||||
|
||||
### Miglioramenti UX
|
||||
1. **Etichette Leggibili**:
|
||||
* Aggiornato `SchemaDiscoveryService` per rilevare automaticamente la proprietà migliore da usare come etichetta (RagioneSociale, Nome, Descrizione, ecc.).
|
||||
* Implementato ordinamento alfabetico automatico basato sull'etichetta rilevata.
|
||||
|
||||
3. **Refactoring `VirtualDatasetsController`**:
|
||||
* Rimosso il metodo hardcoded `GetBaseDatasetSchema`.
|
||||
* Integrato `SchemaDiscoveryService` per la validazione e la generazione degli schemi dei dataset virtuali.
|
||||
* Risolto un TODO per la determinazione automatica del tipo di campo negli schemi virtuali.
|
||||
|
||||
4. **Registrazione Servizio**:
|
||||
* Registrato `SchemaDiscoveryService` in `Program.cs`.
|
||||
|
||||
## Risultato
|
||||
Il Report Designer ora riflette automaticamente qualsiasi modifica al modello dati (nuove entità, nuovi campi) senza richiedere modifiche manuali al codice del controller. I dataset "core" mantengono le loro descrizioni user-friendly, mentre i nuovi dataset vengono esposti con nomi e descrizioni generati automaticamente.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Sidebar Collapsible and Responsive
|
||||
|
||||
## Obiettivo
|
||||
Rendere il menu laterale (Sidebar) collassabile manualmente e automaticamente responsive (si chiude se la finestra si riduce).
|
||||
|
||||
## Stato
|
||||
Completato.
|
||||
|
||||
## Modifiche Apportate
|
||||
### Frontend
|
||||
- **`src/frontend/src/components/Layout.tsx`**:
|
||||
- Aggiunto stato `isCollapsed`.
|
||||
- Aggiunto hook `useMediaQuery` per rilevare la larghezza dello schermo (`md` breakpoint).
|
||||
- Implementata logica `useEffect` per collassare automaticamente la sidebar su schermi medi (tra `sm` e `md`).
|
||||
- Aggiornato il calcolo della larghezza dinamica (`currentDrawerWidth`) per `AppBar`, `Drawer` e `Main Content`.
|
||||
- Aggiunta transizione CSS per un'animazione fluida.
|
||||
|
||||
- **`src/frontend/src/components/Sidebar.tsx`**:
|
||||
- Aggiunto pulsante di toggle (freccia sinistra/destra) nell'header della sidebar.
|
||||
- Implementata la modalità "collassata":
|
||||
- Nasconde i testi (`ListItemText`).
|
||||
- Nasconde le icone di espansione (`ExpandLess`/`ExpandMore`).
|
||||
- Centra le icone (`ListItemIcon`).
|
||||
- Aggiunge `Tooltip` al passaggio del mouse per mostrare l'etichetta del menu.
|
||||
- Gestione click in modalità collassata: se si clicca una voce con sottomenu, la sidebar si espande automaticamente e apre il sottomenu.
|
||||
|
||||
## Verifica
|
||||
- Testato il toggle manuale.
|
||||
- Testato il comportamento responsive (simulato tramite logica breakpoint).
|
||||
- Verificato che i tooltip appaiano correttamente in modalità collassata.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Tab UX Improvements
|
||||
|
||||
## Overview
|
||||
Improve the user experience of the tab bar above the viewport. The goal is to make it more flexible and user-friendly.
|
||||
|
||||
## Features
|
||||
1. **Middle-click to close**: Allow closing tabs by clicking with the middle mouse button.
|
||||
2. **Drag and Drop**: Allow reordering tabs freely.
|
||||
3. **Tab Groups (Sessions)**: Allow saving the current set of open tabs as a named group/session and restoring it later.
|
||||
4. **Context Menu**: Add a right-click context menu to tabs with options like:
|
||||
- Close
|
||||
- Close Others
|
||||
- Close to Right
|
||||
- Pin Tab (optional, if time permits)
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Dependencies
|
||||
- Install `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`.
|
||||
|
||||
### 2. Context Update (`TabContext.tsx`)
|
||||
- Add `reorderTabs(newOrder: Tab[])` function.
|
||||
- Add state for `tabGroups` (saved in `localStorage`).
|
||||
- Add `saveTabGroup(name: string)` function.
|
||||
- Add `loadTabGroup(name: string)` function.
|
||||
- Add `deleteTabGroup(name: string)` function.
|
||||
- Add `closeOtherTabs(path: string)` function.
|
||||
- Add `closeTabsToRight(path: string)` function.
|
||||
|
||||
### 3. Component Update (`TabsBar.tsx`)
|
||||
- Wrap tabs in `DndContext` and `SortableContext`.
|
||||
- Create a `SortableTab` component.
|
||||
- Implement `onAuxClick` for middle-click closing.
|
||||
- Add a "Tab Groups" button/menu to the right of the tabs.
|
||||
- Show saved groups.
|
||||
- Option to save current session.
|
||||
- Implement a custom Context Menu for tabs.
|
||||
|
||||
## Technical Details
|
||||
- **Storage**: Use `localStorage` for now. Keys: `zentral_tabs`, `zentral_active_tab`, `zentral_tab_groups`.
|
||||
- **Styling**: Use MUI components and system.
|
||||
16
docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md
Normal file
16
docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Tab Flicker Fix
|
||||
|
||||
## Issue
|
||||
The user reports a flicker when clicking a tab before it becomes active.
|
||||
|
||||
## Diagnosis
|
||||
The current implementation of the active tab style uses `borderBottom: isActive ? 2 : 0`. This causes a layout shift (height change or content displacement) of 2px whenever the active state changes. This visual jump is perceived as a flicker.
|
||||
|
||||
## Solution
|
||||
Update the styling to maintain a constant border width but change the color.
|
||||
- Change `borderBottom` to always be `2`.
|
||||
- Change `borderColor` to be `'primary.main'` when active and `'transparent'` when inactive.
|
||||
|
||||
## Plan
|
||||
1. Modify `src/frontend/src/components/TabsBar.tsx`.
|
||||
2. Update `SortableTab` styles.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Fix Apps Tab Translation
|
||||
|
||||
## Stato
|
||||
Completato
|
||||
|
||||
## Descrizione
|
||||
Risolto un problema per cui la tab "Gestione Applicazioni" mostrava il titolo "menu.modules" (chiave di traduzione errata) invece di "Applicazioni".
|
||||
|
||||
## Modifiche
|
||||
- Aggiornato `src/frontend/src/components/widgets/WelcomeWidget.tsx` per usare la chiave di traduzione corretta `menu.apps` invece di `menu.modules`.
|
||||
- Aggiornato `src/frontend/src/contexts/TabContext.tsx` per aggiornare l'etichetta della tab se questa è già aperta ma con un'etichetta diversa. Questo corregge il problema anche per le tab già aperte o salvate in cache con l'etichetta errata.
|
||||
|
||||
## Verifica
|
||||
- Verificato tramite browser che cliccando sul link nel widget o nella sidebar, la tab mostra ora correttamente "Applicazioni".
|
||||
@@ -0,0 +1,18 @@
|
||||
# Global Translation Alignment
|
||||
|
||||
## Stato
|
||||
Completato
|
||||
|
||||
## Descrizione
|
||||
Allineamento completo delle traduzioni in tutto il gestionale. Verifica di stringhe hardcoded, chiavi mancanti e supporto accessibilità.
|
||||
|
||||
## Piano di Lavoro
|
||||
1. [x] Analisi struttura i18n esistente.
|
||||
2. [x] Scansione frontend per stringhe hardcoded.
|
||||
3. [x] Scansione backend per messaggi utente non localizzati.
|
||||
4. [x] Aggiornamento file di traduzione (IT/EN).
|
||||
5. [x] Verifica accessibilità (aria-labels, alt text).
|
||||
6. [x] Test cambio lingua.
|
||||
|
||||
## Log
|
||||
- Creazione piano di lavoro.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Traduzione Modulo Acquisti
|
||||
|
||||
## Obiettivo
|
||||
Tradurre completamente il modulo acquisti (Purchases) in italiano e inglese, eliminando le stringhe hardcoded.
|
||||
|
||||
## File da analizzare
|
||||
- `src/frontend/src/apps/purchases/pages/PurchaseOrderFormPage.tsx`
|
||||
- `src/frontend/src/apps/purchases/pages/PurchaseOrdersPage.tsx`
|
||||
- `src/frontend/src/apps/purchases/pages/SupplierFormPage.tsx`
|
||||
- `src/frontend/src/apps/purchases/pages/SuppliersPage.tsx`
|
||||
- `src/frontend/src/apps/purchases/components/PurchasesStatsWidget.tsx`
|
||||
|
||||
## Piano di lavoro
|
||||
1. Analizzare i file per identificare le stringhe hardcoded.
|
||||
2. Aggiungere le chiavi di traduzione in `it/translation.json` e `en/translation.json`.
|
||||
3. Aggiornare i componenti React per utilizzare `useTranslation`.
|
||||
4. Verificare la build.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Traduzione Menu, Search Bar e Tab
|
||||
|
||||
## Obiettivo
|
||||
Tradurre completamente i componenti di navigazione principale: Sidebar (Menu), Search Bar e TabsBar.
|
||||
|
||||
## File da analizzare
|
||||
- `src/frontend/src/components/Sidebar.tsx`
|
||||
- `src/frontend/src/components/SearchBar.tsx`
|
||||
- `src/frontend/src/components/TabsBar.tsx`
|
||||
|
||||
## Piano di lavoro
|
||||
1. Analizzare `Sidebar.tsx` per le voci di menu hardcoded.
|
||||
2. Analizzare `SearchBar.tsx` per placeholder e testi hardcoded.
|
||||
3. Analizzare `TabsBar.tsx` per i titoli delle tab e menu contestuali.
|
||||
4. Aggiungere le chiavi mancanti in `it/translation.json` e `en/translation.json`.
|
||||
5. Aggiornare i componenti per usare `useTranslation`.
|
||||
|
||||
## Stato
|
||||
- **Completato**: 2025-12-06 02:05:00
|
||||
- Aggiunte chiavi di traduzione per menu, navigazione e tab.
|
||||
- Aggiornati i componenti `Sidebar.tsx`, `SearchBar.tsx` e `TabsBar.tsx`.
|
||||
30
docs/development/devlog/2025-12-06-021000_autocodes_reorg.md
Normal file
30
docs/development/devlog/2025-12-06-021000_autocodes_reorg.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Riorganizzazione Auto Codes
|
||||
|
||||
## Obiettivo
|
||||
Riorganizzare la sezione "Auto Codes" per allinearla graficamente e strutturalmente alla sezione "Custom Fields", migliorando le traduzioni e la categorizzazione.
|
||||
|
||||
## Stato Attuale
|
||||
- La pagina `AutoCodesAdminPage.tsx` funziona ma ha nomi di moduli hardcoded in `types/autoCode.ts`.
|
||||
- La struttura grafica è simile ma può essere migliorata per essere identica a `CustomFieldsAdminPage`.
|
||||
- Mancano alcune traduzioni e la categorizzazione potrebbe non essere aggiornata con gli ultimi moduli.
|
||||
|
||||
## Piano di Lavoro
|
||||
1. **Analisi e Preparazione**
|
||||
- [x] Identificare le differenze stilistiche tra `AutoCodesAdminPage` e `CustomFieldsAdminPage`.
|
||||
- [x] Identificare le stringhe non tradotte (es. nomi moduli).
|
||||
|
||||
2. **Refactoring Frontend**
|
||||
- [x] Aggiornare `AutoCodesAdminPage.tsx` per usare lo stesso layout di `CustomFieldsAdminPage`.
|
||||
- [x] Sostituire i nomi hardcoded dei moduli con chiavi di traduzione.
|
||||
- [x] Aggiornare `types/autoCode.ts` per rimuovere `appNames` hardcoded o mapparlo su chiavi i18n.
|
||||
|
||||
3. **Aggiornamento Traduzioni**
|
||||
- [x] Aggiungere le chiavi mancanti in `public/locales/it/translation.json`.
|
||||
- [x] Aggiungere le chiavi mancanti in `public/locales/en/translation.json`.
|
||||
|
||||
4. **Verifica**
|
||||
- [x] Verificare che la pagina si carichi correttamente.
|
||||
- [x] Verificare che le traduzioni funzionino.
|
||||
- [x] Verificare che la categorizzazione sia corretta.
|
||||
- [x] Aggiornare `AutoCodeDto` nel frontend per usare `moduleCode`.
|
||||
- [x] Creare migrazione per aggiornare `ModuleCode` nel database per le entità esistenti.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Traduzione Tab
|
||||
|
||||
## Problema
|
||||
Le tab aperte non venivano tradotte dinamicamente al cambio lingua perché il titolo (label) veniva salvato come stringa statica nel `TabContext` (e persistito in localStorage).
|
||||
|
||||
## Soluzione
|
||||
1. Aggiornato `TabContext.tsx`:
|
||||
- Aggiunta proprietà opzionale `translationKey` all'interfaccia `Tab`.
|
||||
- Aggiornata la funzione `openTab` per accettare e salvare `translationKey`.
|
||||
- Aggiornato il caricamento iniziale (default tab) per includere la chiave di traduzione.
|
||||
|
||||
2. Aggiornato `Sidebar.tsx`:
|
||||
- Aggiunta proprietà `translationKey` alla struttura del menu.
|
||||
- Passaggio della chiave di traduzione alla funzione `openTab` al click.
|
||||
|
||||
3. Aggiornato `SearchBar.tsx`:
|
||||
- Aggiunta proprietà `translationKey` alle opzioni di ricerca.
|
||||
- Passaggio della chiave di traduzione alla funzione `openTab` alla selezione.
|
||||
|
||||
4. Aggiornato `TabsBar.tsx`:
|
||||
- Utilizzo di `t(tab.translationKey)` se disponibile, altrimenti fallback su `tab.label`.
|
||||
- Questo garantisce che le tab cambino lingua istantaneamente quando l'utente cambia lingua.
|
||||
|
||||
## Stato
|
||||
- **Completato**: 2025-12-06 02:15:00
|
||||
- Le tab ora supportano la traduzione dinamica.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Implementazione Modulo Formazione (Generale)
|
||||
|
||||
## Obiettivo
|
||||
Creare un modulo generale per la gestione della formazione (Training), permettendo all'utente di definire corsi di diverso tipo (es. Sicurezza, Tecnici, Qualità, Soft Skills) in base alle esigenze del business. Il sistema gestirà scadenze, attestati e partecipanti in modo agnostico rispetto al tipo di corso.
|
||||
|
||||
## Strategia
|
||||
Mapping delle funzionalità sui moduli esistenti:
|
||||
1. **Anagrafica Corsi** -> Modulo **Magazzino** (`Articolo`)
|
||||
- Viene introdotta una **Classificazione Specifica** tramite property `Tipo` (`Standard`, `Corso`, `Servizio`).
|
||||
- I Corsi saranno `Articolo` con `Tipo = Corso`.
|
||||
- La `Categoria` (Merceologica) sarà usata per il raggruppamento (es. "Sicurezza", "IT").
|
||||
- Il campo `GiorniValidita` gestirà la durata della validità dell'attestato.
|
||||
2. **Anagrafica Soggetti** -> Modulo **Clienti** (`Cliente` + nuova entità `ClienteContatto`)
|
||||
3. **Gestione Attestati e Scadenze** -> Nuovo Modulo **Training** (Formazione)
|
||||
4. **Workflow Notifiche** -> Human-in-the-loop tramite Dashboard dedicato.
|
||||
|
||||
## Piano di Lavoro
|
||||
|
||||
### 1. Documentazione e Analisi
|
||||
- [x] Creazione piano di lavoro (questo file).
|
||||
- [x] Aggiornamento `ZENTRAL.md`.
|
||||
|
||||
### 2. Backend (.NET)
|
||||
#### Domain Layer
|
||||
- [x] **Refactoring Categorie (Warehouse)**:
|
||||
- Implementare gestione **Gruppi Merceologici a 3 livelli** (Standardizzazione Classificazione).
|
||||
- Utilizzare la categoria "Formazione" come root per identificare i corsi.
|
||||
- [x] **Modifica Entity `Articolo`**:
|
||||
- Aggiungere gestione **Validità/Scadenza Standard** (es. `int? GiorniValidita`).
|
||||
- Il campo sarà utilizzato per calcolare la data di scadenza del corso una volta erogato.
|
||||
- [x] **Nuova Entity `ClienteContatto`**:
|
||||
- Proprietà: `Nome`, `Cognome`, `Email`, `Ruolo`, `Telefono`, foreign key a `Cliente`.
|
||||
- Aggiornare `Cliente` con collection `Contatti`.
|
||||
- [x] **Nuova Entity `TrainingRecord`**:
|
||||
- Rappresenta l'avvenuta formazione per un contatto.
|
||||
- Proprietà: `ClienteContattoId`, `ArticoloId` (Corso), `DataEsecuzione`, `DataScadenza` (Calcolata), `AttestatoUrl`, `Stato` (Valid, Expiring, Expired), `Note`.
|
||||
- Entità generica per qualsiasi tipo di corso.
|
||||
|
||||
#### Infrastructure / EF Core
|
||||
- [x] Creare Migrazione EF per le nuove entità e modifiche.
|
||||
- [x] Aggiornare `ApplicationDbContext`.
|
||||
|
||||
#### API Layer
|
||||
- [x] **Aggiornare `ArticoliController`**: Gestione nuovi campi (Validità, Categorie).
|
||||
- [x] **Gestione Classificazioni**: Implementare API per gestire la gerarchia (o livelli) delle categorie merceologiche.
|
||||
- [x] **Aggiornare `ClientiController`**: Gestione CRUD Contatti.
|
||||
- [x] **Nuovo `TrainingController`**:
|
||||
- CRUD TrainingRecords.
|
||||
- Upload file attestato.
|
||||
- Endpoint `GetExpiringTrainings` per la dashboard (filtri per data, azienda, categoria corso).
|
||||
- Endpoint `approve-notification`: Invio email notifiche scadenze.
|
||||
|
||||
### 3. Frontend (React)
|
||||
#### Modulo Training (Nuova App `training`)
|
||||
- [x] **Setup Modulo**: Creare cartella `src/frontend/src/apps/training` e configurare route.
|
||||
- [x] **Componenti**:
|
||||
- `TrainingDashboard`: Widget con scadenze imminenti e scadute, grafici per tipologia corso.
|
||||
- `CourseRegistry`: Tabella corsi (Articoli filtrati per categoria "Formazione"). Permette di creare nuovi corsi e gestire le sottocategorie (Tipi di corso).
|
||||
- `TrainingMatrix`: Vista partecipanti x corsi o lista formazioni.
|
||||
- `TrainingForm`: Modale inserimento/modifica formazione (Caricamento file, calcolo automatico scadenza basato sul corso).
|
||||
|
||||
#### Integrazione Moduli Esistenti
|
||||
- [x] **Magazzino**: Gestione UI per Classificazioni a 3 livelli (Gruppo/Famiglia). (Implementato selezione sottocategorie in RegistryPage)
|
||||
- [x] **Magazzino**: Aggiungere campi Validità/Scadenza nel form Articolo.
|
||||
- [x] **Clienti**: Aggiungere Tab "Contatti" nel dettaglio Cliente per gestire i lavoratori/partecipanti.
|
||||
- [x] **UI**: Aggiungere "Training" a `Sidebar.tsx` e `SearchBar.tsx`.
|
||||
|
||||
### 4. Workflow e Notifiche
|
||||
- [x] Implementare logica "Human-in-the-loop": Liste "Da Inviare" nella Dashboard. (Aggiunto pulsante invio notifica)
|
||||
- [x] Integrazione con il Modulo Email per invio solleciti scadenze.
|
||||
|
||||
### 5. Verifica e Test
|
||||
- [ ] Test flusso completo:
|
||||
1. Creazione "Tipo Corso" (Sottocategoria).
|
||||
2. Creazione Corso con validità.
|
||||
3. Creazione Contatto.
|
||||
4. Registrazione Formazione.
|
||||
5. Verifica Scadenza e Notifica.
|
||||
|
||||
## Stato Attuale
|
||||
- Implementazione Core (Backend/Frontend) completata.
|
||||
- Integrazione Modulo Comunicazioni completata (Controllo attivazione app + invio email).
|
||||
- 2025-12-12-174800_rimosse_tab_interne_modulo_formazione: Rimosse le tab interne (Dashboard, Registry, Matrix) dal layout del modulo Formazione in quanto ridondanti rispetto alla navigazione principale.
|
||||
- 2025-12-12-185000_integrazione_comunicazioni_formazione: Implementata integrazione formale con modulo Comunicazioni (Check AppService + logging).
|
||||
- 2025-12-12-190500_fix_seed_db: Risolto bug mancata creazione categoria "Formazione" (TRAIN) nel seed del database per database esistenti.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Implementazione Modulo Comunicazioni (Ex Email Standard)
|
||||
|
||||
## Obiettivo
|
||||
Implementare il modulo **Comunicazioni** (`communications`), inizialmente focalizzato sulla gestione centralizzata dell'invio email (SMTP).
|
||||
Questo modulo servirà da fondamento per tutte le comunicazioni in uscita (e in futuro interne) del gestionale.
|
||||
|
||||
## Strategia
|
||||
Il modulo gestirà sia l'infrastruttura tecnica (Service Layer per invio mail) sia l'interfaccia utente per la configurazione e il monitoraggio (Log).
|
||||
Sarà allineato alla visione del modulo "Comunicazioni" (Gestione invio mail, chat interna, ecc.).
|
||||
|
||||
## Piano di Lavoro
|
||||
|
||||
### 1. Documentazione
|
||||
- [x] Aggiornamento piano di lavoro (questo file).
|
||||
- [x] Aggiornamento `ZENTRAL.md`.
|
||||
|
||||
### 2. Backend (.NET)
|
||||
#### Domain Layer (`Zentral.Domain`)
|
||||
- [x] **Interfaccia `IEmailSender`**: Contratto standard per l'invio.
|
||||
- [x] **Entities (Namespace `Communications`)**:
|
||||
- `EmailLog`: Storico invii (`Id`, `Data`, `Mittente`, `Destinatario`, `Oggetto`, `Stato`, `Errore`).
|
||||
- `EmailTemplate` (Opzionale Fase 1): Per standardizzare il layout delle mail.
|
||||
|
||||
#### Infrastructure Layer (`Zentral.Infrastructure`)
|
||||
- [x] **Implementazione `SmtpEmailSender`**:
|
||||
- Logica di invio tramite MailKit.
|
||||
- Integrazione con `Configurazione` per leggere le credenziali SMTP a runtime.
|
||||
- Salvataggio automatico del log in `EmailLog`.
|
||||
|
||||
#### API Layer (`Zentral.API`)
|
||||
- [x] **Controller `CommunicationsController`**:
|
||||
- Endpoint per test invio.
|
||||
- Endpoint per consultazione Logs.
|
||||
- Endpoint per salvataggio Configurazione SMTP.
|
||||
|
||||
### 3. Frontend (React)
|
||||
#### Modulo `communications` (`src/apps/communications`)
|
||||
- [x] **Setup App**: Creazione struttura standard modulo.
|
||||
- [x] **Settings Page**:
|
||||
- Form per configurazione SMTP (Host, Port, User, Pass, SSL).
|
||||
- Pulsante "Test Connessione".
|
||||
- [x] **Logs Page**:
|
||||
- Tabella visualizzazione storico email inviate con stato (Successo/Errore).
|
||||
|
||||
## Integrazione
|
||||
- Il servizio `IEmailSender` sarà iniettato negli altri moduli (es. Safety) per l'invio delle notifiche.
|
||||
|
||||
## Verifica
|
||||
- [ ] Configurazione SMTP (es. Mailtrap).
|
||||
- [ ] Test invio mail da interfaccia.
|
||||
- [ ] Verifica scrittura Log su DB.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Implementazione Configurazione Email in Amministrazione
|
||||
|
||||
## Obiettivo
|
||||
Rendere disponibile la configurazione dell'invio email del modulo Comunicazioni nella sezione Amministrazione dell'interfaccia grafica.
|
||||
|
||||
## Stato Attuale
|
||||
- Il backend ha già gli endpoint per la configurazione SMTP (`api/communications/config`).
|
||||
- Esiste già una pagina `SettingsPage` nel modulo Comunicazioni (`src/frontend/src/apps/communications/pages/SettingsPage.tsx`) che gestisce il form di configurazione.
|
||||
- Il modulo Comunicazioni non è attualmente visibile nel menu principale se non attivo/acquistato, ma la configurazione email è un setting globale che dovrebbe essere accessibile.
|
||||
|
||||
## Piano di Lavoro
|
||||
1. **Aggiornamento Route**: Aggiungere una route `/admin/email-config` in `App.tsx` che punta alla pagina di configurazione esistente (o un wrapper).
|
||||
2. **Aggiornamento Menu**: Aggiungere la voce "Configurazione Email" nel menu "Amministrazione" in `Sidebar.tsx`.
|
||||
3. **Traduzioni**: Aggiungere le chiavi di traduzione per la nuova voce di menu in `it/translation.json` e `en/translation.json`.
|
||||
4. **Test**: Avviare l'applicazione e verificare che la pagina sia accessibile e funzionante.
|
||||
|
||||
## Dettagli Tecnici
|
||||
- Riutilizzare `src/frontend/src/apps/communications/pages/SettingsPage.tsx`.
|
||||
- La route sarà protetta se necessario, ma accessibile come parte dell'amministrazione.
|
||||
|
||||
## Stato Finale
|
||||
- [x] Aggiunta route `/admin/email-config` in `App.tsx`.
|
||||
- [x] Aggiunta voce menu "Configurazione Email" in `Sidebar.tsx`.
|
||||
- [x] Aggiunte traduzioni IT ed EN.
|
||||
- [x] Installato .NET 9.0 SDK via script locale (`~/.dotnet`).
|
||||
- [x] Installato `dotnet-ef` tool.
|
||||
- [x] Creata migrazione `UpdateCommunicationsModule` e aggiornato il database.
|
||||
- [x] Backend avviato su porta 5000.
|
||||
- [x] Frontend avviato su porta 5173.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Integrazione Supporto Resend per Invio Email
|
||||
|
||||
## Obiettivo
|
||||
Abilitare l'invio di email tramite servizi terzi (Resend) oltre al già presente SMTP, con configurazione via interfaccia grafica.
|
||||
|
||||
## Stato Attuale
|
||||
- Backend: `SmtpEmailSender` gestisce solo SMTP.
|
||||
- Frontend: `SettingsPage` gestisce solo campi SMTP.
|
||||
- DTO: `SmtpConfigDto` limitato a SMTP.
|
||||
|
||||
## Piano di Lavoro
|
||||
1. **Backend DTO**: Aggiornare `SmtpConfigDto` con campi `Provider` e `ResendApiKey`.
|
||||
2. **Backend Controller**: Aggiornare `CommunicationsController` per leggere/salvare le nuove configurazioni (`EMAIL_PROVIDER`, `RESEND_API_KEY`).
|
||||
3. **Backend Service**: Modificare `SmtpEmailSender` (o rinominarlo in `UnifiedEmailSender`) per supportare la logica condizionale (SMTP vs Resend). Implementare l'invio tramite HTTP Client per Resend.
|
||||
4. **Frontend Service**: Aggiornare le definizioni di tipo TypeScript.
|
||||
5. **Frontend UI**: Modificare `SettingsPage` per aggiungere un selettore di provider (SMTP/Resend) e mostrare i campi pertinenti dinamicamente.
|
||||
6. **Traduzioni**: Aggiungere le nuove etichette.
|
||||
|
||||
## Dettagli Tecnici
|
||||
- **API Resend**: Richiesta POST a `https://api.resend.com/emails` con Bearer Token.
|
||||
- **Provider Enum**: "smtp", "resend".
|
||||
- **Defaut**: SMTP per retrocompatibilità.
|
||||
|
||||
## Avanzamento
|
||||
- [x] Backend DTO Update (`SmtpConfigDto`)
|
||||
- [x] Backend Controller Update (`CommunicationsController`)
|
||||
- [x] Backend Service Logic (`SmtpEmailSender` now handles Resend via HTTP)
|
||||
- [x] Frontend Types Update
|
||||
- [x] Frontend UI Update (`SettingsPage.tsx` with Provider selector)
|
||||
- [x] Dependencies (Added `Microsoft.Extensions.Http` to Infrastructure)
|
||||
|
||||
## Note Finali
|
||||
- L'integrazione supporta ora la selezione dinamica tra SMTP e Resend.
|
||||
- La configurazione viene salvata su database (`EMAIL_PROVIDER`, `RESEND_API_KEY`).
|
||||
- Il backend utilizza `IHttpClientFactory` per le chiamate API verso Resend.
|
||||
- UI aggiornata per mostrare campi condizionali.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Implementazione Gruppi Merceologici Magazzino
|
||||
|
||||
## Richiesta
|
||||
Implementare la gestione dei gruppi merceologici per la categorizzazione degli articoli nel modulo magazzino, sia backend che frontend.
|
||||
|
||||
## Stato Attuale
|
||||
- Esiste già una gestione di "Categorie Articoli" (`WarehouseArticleCategory`) che è gerarchica.
|
||||
- "Gruppi Merceologici" (`WarehouseProductGroup`) sarà una nuova entità, probabilmente una classificazione parallela non gerarchica (o piatta) spesso usata per fini statistici o contabili, o semplicemente come raggruppamento alternativo.
|
||||
|
||||
## Piano di Lavoro
|
||||
|
||||
### Backend
|
||||
1. **Domain Layer**
|
||||
- Creare entità `WarehouseProductGroup` in `Zentral.Domain.Entities.Warehouse`.
|
||||
- Campi: Code, Name, Description, IsActive.
|
||||
- Aggiornare `WarehouseArticle` aggiungendo FK `ProductGroupId` e navigation property.
|
||||
2. **Infrastructure Layer**
|
||||
- Aggiungere `DbSet<WarehouseProductGroup>` in `ApplicationDbContext`.
|
||||
- Configurare le relazioni entity framework se necessario.
|
||||
- Creare Migrazione `AddWarehouseProductGroups`.
|
||||
3. **Service Layer**
|
||||
- Aggiornare `IWarehouseService` e `WarehouseService` con i metodi CRUD per i gruppi merceologici.
|
||||
4. **API Layer**
|
||||
- Creare `WarehouseProductGroupsController`.
|
||||
- Aggiornare DTOs degli articoli per includere `ProductGroupId`.
|
||||
|
||||
### Frontend
|
||||
1. **Services**
|
||||
- Creare `productGroupService.ts` per chiamare le API.
|
||||
2. **Pages**
|
||||
- Creare `ProductGroupsPage` per elenco e gestione (CRUD).
|
||||
3. **Components**
|
||||
- Aggiornare il form di creazione/modifica articolo per permettere la selezione del gruppo merceologico.
|
||||
4. **Routing & Navigation**
|
||||
- Aggiungere rotta per `ProductGroupsPage`.
|
||||
- Aggiungere voce di menu nella sidebar del magazzino.
|
||||
|
||||
## Note
|
||||
- L'implementazione seguirà lo stile esistente del modulo Warehouse, usando Services e Controllers.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Sostituzione Gruppi Merceologici con Categorie Gerarchiche
|
||||
|
||||
## Stato Corrente
|
||||
IMPLEMENTATO
|
||||
|
||||
## Descrizione
|
||||
Sostituita la gestione separata dei "Gruppi Merceologici" con l'utilizzo potenziato delle Categorie Articoli (`WarehouseArticleCategory`) già esistenti e gerarchiche.
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### Backend
|
||||
- **Revert**: Rimossa entity `WarehouseProductGroup` e relativi controller e service.
|
||||
- **Migration**: Creata e applicata migrazione `RemoveWarehouseProductGroups` per rimuovere la tabella dal database.
|
||||
- **Services**: `WarehouseService` ripulito da logica `ProductGroups`.
|
||||
|
||||
### Frontend
|
||||
- **Revert**: Rimossa pagina `ProductGroupsPage` e riferimenti nel codice.
|
||||
- **New Feature**: Creata pagina `CategoriesPage` (`/warehouse/categories`) per gestire le categorie in modalità albero.
|
||||
- Create
|
||||
- Update
|
||||
- Delete
|
||||
- Struttura gerarchica visualizzata (Tree View).
|
||||
- **Article Form**: Rimossa selezione "Gruppo Merceologico". La selezione della categoria utilizza `CategoryTree` appiattito per la selezione.
|
||||
- **Navigation**: Aggiunto link "Categorie" nella sidebar del Magazzino.
|
||||
|
||||
## Note Tecniche
|
||||
- La gestione delle categorie sfrutta la ricorsività supportata dall'entity `WarehouseArticleCategory`.
|
||||
- L'interfaccia utente permette di gestire la gerarchia creando categorie "root" o sottocategorie.
|
||||
|
||||
## Verifica
|
||||
- **Backend API**:
|
||||
- `GET /api/warehouse/categories` -> Disponibile.
|
||||
- `GET /api/warehouse/categories/tree` -> Disponibile (ritorna JSON corretto).
|
||||
- `GET /api/warehouse/product-groups` -> **404 Not Found** (Correttamente rimosso).
|
||||
@@ -0,0 +1,21 @@
|
||||
# Update Translations for New Developments
|
||||
|
||||
## Status
|
||||
- [x] Analysis of new features needing translation
|
||||
- [x] Update Italian Translations (it)
|
||||
- [x] Update English Translations (en)
|
||||
- [x] Verification
|
||||
|
||||
## Details
|
||||
Verified recent developments:
|
||||
1. **Warehouse - Categories**: New management of article categories.
|
||||
2. **Communications**: Email configuration and logs.
|
||||
3. **Training**: New module for courses and training sessions.
|
||||
|
||||
I will scan these modules for `t()` calls and update the `translation.json` files in `public/locales/it` and `public/locales/en`.
|
||||
|
||||
## Work Done
|
||||
- **Warehouse Categories**: Updated `CategoriesPage.tsx` to use `useTranslation`. Added keys for titles, buttons, fields, and dialogs in both IT and EN locales.
|
||||
- **Communications**: Updated `SettingsPage.tsx` and `LogsPage.tsx` to use `useTranslation`. Added complete set of keys for settings, fields, actions, messages and log columns in both IT and EN locales.
|
||||
- **Components**: Updated `Sidebar.tsx`, `SearchBar.tsx` to use full translations. Added `apps.core.title` and ensure `categories` is available in menu.
|
||||
- **Training**: Training module files were not found in the current workspace, so no translations were applied for this module yet. Suggest to review separately when module is available.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Analisi Funzionale e Piano di Implementazione: Modulo Formazione Obbligatoria
|
||||
|
||||
## 1. Introduzione e Obiettivi
|
||||
La presente analisi definisce le specifiche per l'estensione del sistema **Zentral** (progetto "OBIS" nel contesto cliente) con un modulo dedicato alla **Gestione della Formazione Obbligatoria**.
|
||||
L'obiettivo è integrare nativamente la gestione di aziende, lavoratori, corsi, scadenze e attestati, automatizzando il calcolo delle validità e il workflow di notifica ai referenti aziendali.
|
||||
|
||||
## 2. Requisiti Funzionali
|
||||
|
||||
### 2.1 Gestione Anagrafiche
|
||||
Il sistema deve sfruttare le entità esistenti estendendone la logica di presentazione e filtraggio.
|
||||
- **Aziende e Sedi**: Mapping su `Cliente`.
|
||||
- **Funzionalità**: Attivazione/disattivazione (campo `Attivo`), storicizzazione (implicita nel non cancellare i dati), gestione sedi (già presente o gestibile tramite indirizzi multipli/destinazioni o clienti gerarchici. *Decisione*: Usare `Cliente` standard. Se necessario "Sede", si useranno i campi indirizzo o clienti collegati).
|
||||
- **Lavoratori**: Mapping su `ClienteContatto`.
|
||||
- **Funzionalità**: Ricerca trasversale (Global Search), filtri per Azienda, Ruolo, Stato Formativo.
|
||||
- **Dati**: Nome, Cognome, Ruolo (es. "Saldatore", "Impiegato"), Email, Telefono.
|
||||
|
||||
### 2.2 Catalogo Corsi
|
||||
Il catalogo corsi è il "motore" delle regole di scadenza.
|
||||
- **Mapping**: `Articolo` con Categoria "Formazione".
|
||||
- **Configurazione**:
|
||||
- **Tipologia**: Definita tramite sottocategorie merceologiche (es. Sicurezza > Basso Rischio).
|
||||
- **Validità**: Campo `GiorniValidita` (già implementato) per calcolo automatico scadenza.
|
||||
- **Logica Aggiornamento**: Definizione se un corso è aggiornamento di un altro (facoltativo, logica avanzata).
|
||||
|
||||
### 2.3 Registro Formazione ed Eventi
|
||||
Centralizzazione dello storico formativo.
|
||||
- **Mapping**: `TrainingRecord`.
|
||||
- **Funzionalità**:
|
||||
- Registrazione partecipazione lavoratore a corso.
|
||||
- **Calcolo Stati**:
|
||||
- *Valido*: Corso effettuato e non scaduto.
|
||||
- *In Pre-scadenza*: Meno di X giorni alla scadenza (configurabile, es. 30 o 60 gg).
|
||||
- *Scaduto*: Data odierna > Data Scadenza.
|
||||
- **Attestati**: Upload PDF/JPG, anteprima, download, archiviazione.
|
||||
|
||||
### 2.4 Scadenzario Interattivo (Dashboard)
|
||||
Strumento principale per l'operatore.
|
||||
- **Visualizzazione**: Tabellare avanzata (Data Grid).
|
||||
- **Colonne Chiave**: Lavoratore, Azienda, Corso, Data Esecuzione, Data Scadenza, Stato, Azioni.
|
||||
- **Filtri**:
|
||||
- Per Azienda/Sede.
|
||||
- Per Tipologia Corso.
|
||||
- Range Date Scadenza.
|
||||
- Stato (Mostra solo Scaduti/In Scadenza).
|
||||
- **Export**: Funzione diretta "Esporta in Excel" della vista filtrata.
|
||||
|
||||
### 2.5 Sistema di Notifiche (Workflow Approvativo)
|
||||
Il sistema non deve inviare email "a pioggia" ai lavoratori, ma notifiche controllate ai referenti.
|
||||
- **Target**: Referente Aziendale (identificato nel `Cliente` o un `ClienteContatto` specifico marcato come "Referente Formazione").
|
||||
- **Tipologie**:
|
||||
- *Pre-scadenza*: Avviso X giorni prima.
|
||||
- *Scadenza*: Avviso il giorno stesso o settimana stessa.
|
||||
- *Post-scadenza*: Sollecito.
|
||||
- **Coda di Invio (Queue)**:
|
||||
- Le email **non** partono subito. Vengono generate in stato `Pending` in una tabella dedicata (`TrainingNotificationQueue`).
|
||||
- **Interfaccia di Review**: L'operatore vede le email pronte, può selezionarle, modificarle (opzionale) e approvarne l'invio.
|
||||
- **Template**:
|
||||
- Supporto per template standard (Oggetto e Corpo configurabili con placeholder `{Azienda}`, `{Lavoratore}`, `{Corso}`, `{Scadenza}`).
|
||||
|
||||
### 2.6 Import/Export Anagrafiche
|
||||
- **Import Massivo**: Upload file Excel per popolare/aggiornare `ClienteContatto` (Lavoratori) e storico `TrainingRecord`.
|
||||
- **Export E-learning**: Esportazione CSV/XLS su tracciati specifici (da definire, genericamente "Campi Anagrafici Base") per import su piattaforme esterne.
|
||||
|
||||
---
|
||||
|
||||
## 3. Piano di Implementazione Tecnico
|
||||
|
||||
### Phase 1: Backend Extension & Data Model
|
||||
1. **Entities**:
|
||||
- Verificare `TrainingRecord` (già esistente).
|
||||
- Creare `TrainingNotification` (Queue):
|
||||
- `Id`, `TrainingRecordId`, `RecipientEmail`, `Subject`, `Body`, `ScheduledDate`, `SentDate`, `Status` (Pending, Approved, Sent, Error).
|
||||
- Creare `ImportJob` (opzionale, o gestione diretta API).
|
||||
2. **API Controllers**:
|
||||
- `TrainingController`:
|
||||
- Endpoint `GetDeadlines`: Query complessa con filtri, paginazione ordinamento.
|
||||
- Endpoint `ExportDeadlines`: Generazione Excel.
|
||||
- Endpoint `ImportData`: Parsing Excel e bulk insert.
|
||||
- Endpoint `GenerateNotifications`: Job (o trigger) per popolare la coda notifiche in base alle scadenze.
|
||||
- Endpoint `SendNotifications`: Invio massivo delle notifiche approvate.
|
||||
|
||||
### Phase 2: Frontend Implementation (App `training`)
|
||||
1. **Views (Pagine)**:
|
||||
- **Scadenzario (`TrainingDeadlinesPage`)**:
|
||||
- Datagrid avanzata (libreria UI o custom table con filtri).
|
||||
- Bottone "Esporta Excel".
|
||||
- **Code Notifiche (`NotificationCenterPage`)**:
|
||||
- Lista email in attesa.
|
||||
- Checkbox selezione multipla -> Azione "Approva e Invia".
|
||||
- Preview email side-by-side.
|
||||
- **Registro Lavoratori (`WorkersRegistryPage`)**:
|
||||
- Vista incentrata sui `ClienteContatto` con focus formazione (colonne: Ultimi corsi, Stato generale).
|
||||
- **Import/Export Utility (`DataExchangePage`)**:
|
||||
- Upload file Excel, mapping colonne (semplificato), log risultati import.
|
||||
|
||||
### Phase 3: Integration & Logic
|
||||
1. **Notification Logic**:
|
||||
- Service che scansiona `TrainingRecord` ogni notte (o on-demand), calcola scadenze, controlla se notifica già generata, crea record in `TrainingNotification`.
|
||||
- Logica di raggruppamento: Se un'azienda ha 10 lavoratori in scadenza, inviare 1 email cumulativa al referente o 10 email separate? *Specifiche attuali: "email... indirizzate ai referenti... non ai singoli lavoratori"*.
|
||||
- *Decisione Progettuale*: **Email Raggruppata per Referente**. Il sistema deve raggruppare le scadenze per Azienda e generare una sola notifica con la lista dei lavoratori in scadenza.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nuove Rotte e Struttura File (Preview)
|
||||
|
||||
### Backend
|
||||
- `src/backend/Zentral.Domain/Entities/Training/TrainingNotification.cs`
|
||||
- `src/backend/Zentral.API/Modules/Training/Controllers/TrainingNotificationsController.cs`
|
||||
- `src/backend/Zentral.API/Modules/Training/Services/NotificationGeneratorService.cs`
|
||||
- `src/backend/Zentral.API/Modules/Training/Services/ExcelImportService.cs`
|
||||
|
||||
### Frontend
|
||||
- `src/frontend/src/apps/training/pages/TrainingDeadlinesPage.tsx`
|
||||
- `src/frontend/src/apps/training/pages/NotificationCenterPage.tsx`
|
||||
- `src/frontend/src/apps/training/pages/WorkersRegistryPage.tsx`
|
||||
- `src/frontend/src/apps/training/pages/DataExchangePage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 5. Note Operative
|
||||
- Utilizzare libreria `EPPlus` o `ClosedXML` lato server per Excel, o `SheetJS` lato client se l'export è puramente visivo (preferibile server-side per grandi moli di dati).
|
||||
- Per le Importazioni: Validazione rigorosa Codici Fiscali o Email univoche per evitare duplicati anagrafiche.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Implementazione Modulo Formazione Obbligatoria (Mandatory Training)
|
||||
|
||||
## Stato: Completato
|
||||
|
||||
Ho completato l'implementazione del modulo Formazione Obbligatoria seguendo le specifiche definite in `2025-12-13-164500_mandatory_training_specs.md`.
|
||||
|
||||
## Modifiche Apportate
|
||||
|
||||
### Backend
|
||||
1. **Entities**:
|
||||
- Creata `TrainingNotification` in `Zentral.Domain` per gestire la coda di notifiche.
|
||||
- Aggiornato `ZentralDbContext` (DbSet).
|
||||
- Creata migrazione `AddTrainingNotifications`.
|
||||
2. **Services**:
|
||||
- Creato `TrainingNotificationService`:
|
||||
- Logica `GenerateNotificationsAsync`: raggruppa scadenze per Cliente, crea notifiche `Pending`.
|
||||
- Logica `SendApprovedNotificationsAsync`: invia email per notifiche `Approved`.
|
||||
- Generazione corpo email HTML con tabella riepilogativa.
|
||||
- Registrato servizio in `Program.cs`.
|
||||
3. **Controllers**:
|
||||
- Creato `TrainingNotificationsController`:
|
||||
- Endpoints per Listing, Generazione, Approvazione, Modifica e Invio.
|
||||
- Aggiornato `AppService` (verifica esistenza modulo, usato nei service).
|
||||
|
||||
### Frontend
|
||||
1. **Pagine Nuove (App Training)**:
|
||||
- `TrainingDeadlinesPage`: Scadenzario tabellare con indicatori di stato.
|
||||
- `NotificationCenterPage`: Gestione coda notifiche (Approvazione/Modifica/Invio).
|
||||
- `WorkersRegistryPage`: Registro lavoratori con stato formativo aggregato.
|
||||
- `DataExchangePage`: Placeholder per Import/Export.
|
||||
2. **Navigazione**:
|
||||
- Aggiornato `Sidebar.tsx` con le nuove voci di menu sotto "Formazione" ("Lavoratori", "Scadenze", "Notifiche", "Import/Export").
|
||||
- Aggiornato `routes.tsx` con le relative rotte.
|
||||
|
||||
## Note per il Testing
|
||||
- Per testare le notifiche:
|
||||
1. Andare in "Notifiche".
|
||||
2. Cliccare "Genera".
|
||||
3. Verificare la creazione di notifiche per le aziende con scadenze.
|
||||
4. Approvare una notifica.
|
||||
5. Cliccare "Invia Approvate".
|
||||
- Assicurarsi che il modulo "Comunicazioni" sia attivo e configurato (SMTP).
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.API.Apps.Communications.Dtos;
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Zentral.API.Apps.Communications.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/communications")]
|
||||
public class CommunicationsController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly IEmailSender _emailSender;
|
||||
|
||||
public CommunicationsController(ZentralDbContext context, IEmailSender emailSender)
|
||||
{
|
||||
_context = context;
|
||||
_emailSender = emailSender;
|
||||
}
|
||||
|
||||
[HttpGet("config")]
|
||||
public async Task<ActionResult<SmtpConfigDto>> GetConfig()
|
||||
{
|
||||
var configs = await _context.Configurazioni
|
||||
.Where(c => c.Chiave.StartsWith("SMTP_") || c.Chiave == "EMAIL_PROVIDER" || c.Chiave == "RESEND_API_KEY")
|
||||
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
|
||||
|
||||
var dto = new SmtpConfigDto
|
||||
{
|
||||
Host = GetValue(configs, "SMTP_HOST"),
|
||||
Port = int.Parse(GetValue(configs, "SMTP_PORT", "587")),
|
||||
User = GetValue(configs, "SMTP_USER"),
|
||||
Password = GetValue(configs, "SMTP_PASS"),
|
||||
EnableSsl = bool.Parse(GetValue(configs, "SMTP_SSL", "false")),
|
||||
FromEmail = GetValue(configs, "SMTP_FROM_EMAIL"),
|
||||
FromName = GetValue(configs, "SMTP_FROM_NAME"),
|
||||
Provider = GetValue(configs, "EMAIL_PROVIDER", "smtp"),
|
||||
ResendApiKey = GetValue(configs, "RESEND_API_KEY")
|
||||
};
|
||||
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost("config")]
|
||||
public async Task<ActionResult> SaveConfig(SmtpConfigDto dto)
|
||||
{
|
||||
await SetConfig("SMTP_HOST", dto.Host);
|
||||
await SetConfig("SMTP_PORT", dto.Port.ToString());
|
||||
await SetConfig("SMTP_USER", dto.User);
|
||||
await SetConfig("SMTP_PASS", dto.Password);
|
||||
await SetConfig("SMTP_SSL", dto.EnableSsl.ToString().ToLower());
|
||||
await SetConfig("SMTP_FROM_EMAIL", dto.FromEmail);
|
||||
await SetConfig("SMTP_FROM_NAME", dto.FromName);
|
||||
|
||||
await SetConfig("EMAIL_PROVIDER", dto.Provider);
|
||||
await SetConfig("RESEND_API_KEY", dto.ResendApiKey);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("send-test")]
|
||||
public async Task<ActionResult> SendTestEmail(TestEmailDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _emailSender.SendEmailAsync(dto.To, dto.Subject, dto.Body);
|
||||
return Ok(new { message = "Email send process initiated. Check logs for status." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("logs")]
|
||||
public async Task<ActionResult<List<EmailLogDto>>> GetLogs([FromQuery] int limit = 50)
|
||||
{
|
||||
var logs = await _context.EmailLogs
|
||||
.OrderByDescending(l => l.SentDate)
|
||||
.Take(limit)
|
||||
.Select(l => new EmailLogDto
|
||||
{
|
||||
Id = l.Id,
|
||||
SentDate = l.SentDate,
|
||||
Sender = l.Sender,
|
||||
Recipient = l.Recipient,
|
||||
Subject = l.Subject,
|
||||
Status = l.Status,
|
||||
ErrorMessage = l.ErrorMessage
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
private string GetValue(Dictionary<string, string?> dict, string key, string def = "")
|
||||
{
|
||||
return dict.ContainsKey(key) && dict[key] != null ? dict[key]! : def;
|
||||
}
|
||||
|
||||
private async Task SetConfig(string key, string? value)
|
||||
{
|
||||
var config = await _context.Configurazioni.FirstOrDefaultAsync(c => c.Chiave == key);
|
||||
if (config == null)
|
||||
{
|
||||
config = new Configurazione { Chiave = key, CreatedAt = DateTime.UtcNow, CreatedBy = User.FindFirstValue(ClaimTypes.Name) ?? "System" };
|
||||
_context.Configurazioni.Add(config);
|
||||
}
|
||||
config.Valore = value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = User.FindFirstValue(ClaimTypes.Name) ?? "System";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace Zentral.API.Apps.Communications.Dtos;
|
||||
|
||||
public class EmailLogDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime SentDate { get; set; }
|
||||
public string Sender { get; set; } = string.Empty;
|
||||
public string Recipient { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Zentral.API.Apps.Communications.Dtos;
|
||||
|
||||
public class SmtpConfigDto
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public int Port { get; set; } = 587;
|
||||
public string User { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public bool EnableSsl { get; set; } = false;
|
||||
public string FromEmail { get; set; } = string.Empty;
|
||||
public string FromName { get; set; } = string.Empty;
|
||||
|
||||
// New fields for Resend support
|
||||
public string Provider { get; set; } = "smtp"; // "smtp" or "resend"
|
||||
public string ResendApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Zentral.API.Apps.Communications.Dtos;
|
||||
|
||||
public class TestEmailDto
|
||||
{
|
||||
public string To { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = "Test Email from Zentral";
|
||||
public string Body { get; set; } = "This is a test email sent from Zentral Communications Module.";
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Zentral.Domain.Entities.HR;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Apps.HR.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/hr/[controller]")]
|
||||
public class AssenzeController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
|
||||
public AssenzeController(ZentralDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<Assenza>>> GetAssenze([FromQuery] int? dipendenteId, [FromQuery] DateTime? from, [FromQuery] DateTime? to)
|
||||
{
|
||||
var query = _context.Assenze.Include(a => a.Dipendente).AsQueryable();
|
||||
|
||||
if (dipendenteId.HasValue)
|
||||
query = query.Where(a => a.DipendenteId == dipendenteId.Value);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(a => a.DataInizio >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(a => a.DataFine <= to.Value);
|
||||
|
||||
return await query.OrderByDescending(a => a.DataInizio).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<Assenza>> GetAssenza(int id)
|
||||
{
|
||||
var assenza = await _context.Assenze
|
||||
.Include(a => a.Dipendente)
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (assenza == null)
|
||||
return NotFound();
|
||||
|
||||
return assenza;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Assenza>> CreateAssenza(Assenza assenza)
|
||||
{
|
||||
assenza.CreatedAt = DateTime.UtcNow;
|
||||
_context.Assenze.Add(assenza);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetAssenza), new { id = assenza.Id }, assenza);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateAssenza(int id, Assenza assenza)
|
||||
{
|
||||
if (id != assenza.Id)
|
||||
return BadRequest();
|
||||
|
||||
assenza.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(assenza).State = EntityState.Modified;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.Assenze.AnyAsync(a => a.Id == id))
|
||||
return NotFound();
|
||||
throw;
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteAssenza(int id)
|
||||
{
|
||||
var assenza = await _context.Assenze.FindAsync(id);
|
||||
if (assenza == null)
|
||||
return NotFound();
|
||||
|
||||
_context.Assenze.Remove(assenza);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Zentral.Domain.Entities.HR;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Apps.HR.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/hr/[controller]")]
|
||||
public class ContrattiController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
|
||||
public ContrattiController(ZentralDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<Contratto>>> GetContratti([FromQuery] int? dipendenteId)
|
||||
{
|
||||
var query = _context.Contratti.Include(c => c.Dipendente).AsQueryable();
|
||||
|
||||
if (dipendenteId.HasValue)
|
||||
query = query.Where(c => c.DipendenteId == dipendenteId.Value);
|
||||
|
||||
return await query.OrderByDescending(c => c.DataInizio).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<Contratto>> GetContratto(int id)
|
||||
{
|
||||
var contratto = await _context.Contratti
|
||||
.Include(c => c.Dipendente)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (contratto == null)
|
||||
return NotFound();
|
||||
|
||||
return contratto;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Contratto>> CreateContratto(Contratto contratto)
|
||||
{
|
||||
contratto.CreatedAt = DateTime.UtcNow;
|
||||
_context.Contratti.Add(contratto);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetContratto), new { id = contratto.Id }, contratto);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateContratto(int id, Contratto contratto)
|
||||
{
|
||||
if (id != contratto.Id)
|
||||
return BadRequest();
|
||||
|
||||
contratto.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(contratto).State = EntityState.Modified;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.Contratti.AnyAsync(c => c.Id == id))
|
||||
return NotFound();
|
||||
throw;
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteContratto(int id)
|
||||
{
|
||||
var contratto = await _context.Contratti.FindAsync(id);
|
||||
if (contratto == null)
|
||||
return NotFound();
|
||||
|
||||
_context.Contratti.Remove(contratto);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Zentral.Domain.Entities.HR;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Apps.HR.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/hr/[controller]")]
|
||||
public class DipendentiController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
|
||||
public DipendentiController(ZentralDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<Dipendente>>> GetDipendenti([FromQuery] string? search)
|
||||
{
|
||||
var query = _context.Dipendenti.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
query = query.Where(d => d.Nome.Contains(search) ||
|
||||
d.Cognome.Contains(search) ||
|
||||
(d.CodiceFiscale != null && d.CodiceFiscale.Contains(search)));
|
||||
|
||||
return await query.OrderBy(d => d.Cognome).ThenBy(d => d.Nome).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<Dipendente>> GetDipendente(int id)
|
||||
{
|
||||
var dipendente = await _context.Dipendenti
|
||||
.Include(d => d.Contratti)
|
||||
.Include(d => d.Assenze)
|
||||
.Include(d => d.Pagamenti)
|
||||
.Include(d => d.Rimborsi)
|
||||
.FirstOrDefaultAsync(d => d.Id == id);
|
||||
|
||||
if (dipendente == null)
|
||||
return NotFound();
|
||||
|
||||
return dipendente;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Dipendente>> CreateDipendente(Dipendente dipendente)
|
||||
{
|
||||
dipendente.CreatedAt = DateTime.UtcNow;
|
||||
_context.Dipendenti.Add(dipendente);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetDipendente), new { id = dipendente.Id }, dipendente);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateDipendente(int id, Dipendente dipendente)
|
||||
{
|
||||
if (id != dipendente.Id)
|
||||
return BadRequest();
|
||||
|
||||
dipendente.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(dipendente).State = EntityState.Modified;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.Dipendenti.AnyAsync(d => d.Id == id))
|
||||
return NotFound();
|
||||
throw;
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteDipendente(int id)
|
||||
{
|
||||
var dipendente = await _context.Dipendenti.FindAsync(id);
|
||||
if (dipendente == null)
|
||||
return NotFound();
|
||||
|
||||
_context.Dipendenti.Remove(dipendente);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Zentral.Domain.Entities.HR;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Apps.HR.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/hr/[controller]")]
|
||||
public class PagamentiController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
|
||||
public PagamentiController(ZentralDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<Pagamento>>> GetPagamenti([FromQuery] int? dipendenteId, [FromQuery] DateTime? from, [FromQuery] DateTime? to)
|
||||
{
|
||||
var query = _context.Pagamenti.Include(p => p.Dipendente).AsQueryable();
|
||||
|
||||
if (dipendenteId.HasValue)
|
||||
query = query.Where(p => p.DipendenteId == dipendenteId.Value);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(p => p.DataPagamento >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(p => p.DataPagamento <= to.Value);
|
||||
|
||||
return await query.OrderByDescending(p => p.DataPagamento).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<Pagamento>> GetPagamento(int id)
|
||||
{
|
||||
var pagamento = await _context.Pagamenti
|
||||
.Include(p => p.Dipendente)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (pagamento == null)
|
||||
return NotFound();
|
||||
|
||||
return pagamento;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Pagamento>> CreatePagamento(Pagamento pagamento)
|
||||
{
|
||||
pagamento.CreatedAt = DateTime.UtcNow;
|
||||
_context.Pagamenti.Add(pagamento);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetPagamento), new { id = pagamento.Id }, pagamento);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdatePagamento(int id, Pagamento pagamento)
|
||||
{
|
||||
if (id != pagamento.Id)
|
||||
return BadRequest();
|
||||
|
||||
pagamento.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(pagamento).State = EntityState.Modified;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.Pagamenti.AnyAsync(p => p.Id == id))
|
||||
return NotFound();
|
||||
throw;
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeletePagamento(int id)
|
||||
{
|
||||
var pagamento = await _context.Pagamenti.FindAsync(id);
|
||||
if (pagamento == null)
|
||||
return NotFound();
|
||||
|
||||
_context.Pagamenti.Remove(pagamento);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Zentral.Domain.Entities.HR;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Apps.HR.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/hr/[controller]")]
|
||||
public class RimborsiController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
|
||||
public RimborsiController(ZentralDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<Rimborso>>> GetRimborsi([FromQuery] int? dipendenteId, [FromQuery] string? stato)
|
||||
{
|
||||
var query = _context.Rimborsi.Include(r => r.Dipendente).AsQueryable();
|
||||
|
||||
if (dipendenteId.HasValue)
|
||||
query = query.Where(r => r.DipendenteId == dipendenteId.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(stato))
|
||||
query = query.Where(r => r.Stato == stato);
|
||||
|
||||
return await query.OrderByDescending(r => r.DataSpesa).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<Rimborso>> GetRimborso(int id)
|
||||
{
|
||||
var rimborso = await _context.Rimborsi
|
||||
.Include(r => r.Dipendente)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (rimborso == null)
|
||||
return NotFound();
|
||||
|
||||
return rimborso;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<Rimborso>> CreateRimborso(Rimborso rimborso)
|
||||
{
|
||||
rimborso.CreatedAt = DateTime.UtcNow;
|
||||
_context.Rimborsi.Add(rimborso);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetRimborso), new { id = rimborso.Id }, rimborso);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateRimborso(int id, Rimborso rimborso)
|
||||
{
|
||||
if (id != rimborso.Id)
|
||||
return BadRequest();
|
||||
|
||||
rimborso.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(rimborso).State = EntityState.Modified;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.Rimborsi.AnyAsync(r => r.Id == id))
|
||||
return NotFound();
|
||||
throw;
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteRimborso(int id)
|
||||
{
|
||||
var rimborso = await _context.Rimborsi.FindAsync(id);
|
||||
if (rimborso == null)
|
||||
return NotFound();
|
||||
|
||||
_context.Rimborsi.Remove(rimborso);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
namespace Zentral.API.Apps.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/bom")]
|
||||
@@ -1,9 +1,9 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
namespace Zentral.API.Apps.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/mrp")]
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
namespace Zentral.API.Apps.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/cycles")]
|
||||
@@ -1,9 +1,9 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
namespace Zentral.API.Apps.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/orders")]
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Controllers;
|
||||
namespace Zentral.API.Apps.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/work-centers")]
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class BillOfMaterialsDto
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class CreateBillOfMaterialsDto
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class CreateProductionOrderDto
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class MrpConfigurationDto
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class ProductionCycleDto
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using Zentral.Domain.Entities.Production;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class ProductionOrderDto
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class UpdateBillOfMaterialsDto
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using Zentral.Domain.Entities.Production;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class UpdateProductionOrderDto
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Zentral.API.Modules.Production.Dtos;
|
||||
namespace Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
public class WorkCenterDto
|
||||
{
|
||||
@@ -1,7 +1,7 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Services;
|
||||
namespace Zentral.API.Apps.Production.Services;
|
||||
|
||||
public interface IMrpService
|
||||
{
|
||||
@@ -1,7 +1,7 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Services;
|
||||
namespace Zentral.API.Apps.Production.Services;
|
||||
|
||||
public interface IProductionService
|
||||
{
|
||||
@@ -2,9 +2,9 @@ using Zentral.Domain.Entities.Production;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Services;
|
||||
namespace Zentral.API.Apps.Production.Services;
|
||||
|
||||
public class MrpService : IMrpService
|
||||
{
|
||||
@@ -1,11 +1,11 @@
|
||||
using Zentral.API.Modules.Production.Dtos;
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Production.Dtos;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Modules.Production.Services;
|
||||
namespace Zentral.API.Apps.Production.Services;
|
||||
|
||||
public class ProductionService : IProductionService
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Purchases.Dtos;
|
||||
using Zentral.API.Modules.Purchases.Services;
|
||||
using Zentral.API.Apps.Purchases.Dtos;
|
||||
using Zentral.API.Apps.Purchases.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Controllers;
|
||||
namespace Zentral.API.Apps.Purchases.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchases/orders")]
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Purchases.Dtos;
|
||||
using Zentral.API.Modules.Purchases.Services;
|
||||
using Zentral.API.Apps.Purchases.Dtos;
|
||||
using Zentral.API.Apps.Purchases.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Controllers;
|
||||
namespace Zentral.API.Apps.Purchases.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchases/suppliers")]
|
||||
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using Zentral.Domain.Entities.Purchases;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Dtos;
|
||||
namespace Zentral.API.Apps.Purchases.Dtos;
|
||||
|
||||
public class PurchaseOrderDto
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Dtos;
|
||||
namespace Zentral.API.Apps.Purchases.Dtos;
|
||||
|
||||
public class SupplierDto
|
||||
{
|
||||
@@ -2,15 +2,15 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Purchases.Dtos;
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Purchases.Dtos;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.API.Services;
|
||||
using Zentral.Domain.Entities.Purchases;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Services;
|
||||
namespace Zentral.API.Apps.Purchases.Services;
|
||||
|
||||
public class PurchaseService
|
||||
{
|
||||
@@ -2,13 +2,13 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Purchases.Dtos;
|
||||
using Zentral.API.Apps.Purchases.Dtos;
|
||||
using Zentral.API.Services;
|
||||
using Zentral.Domain.Entities.Purchases;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Modules.Purchases.Services;
|
||||
namespace Zentral.API.Apps.Purchases.Services;
|
||||
|
||||
public class SupplierService
|
||||
{
|
||||
@@ -4,10 +4,10 @@ using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Controllers;
|
||||
namespace Zentral.API.Apps.ReportDesigner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/report-resources")]
|
||||
[Route("api/report-designer/resources")]
|
||||
public class ReportResourcesController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
@@ -4,10 +4,10 @@ using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Controllers;
|
||||
namespace Zentral.API.Apps.ReportDesigner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/report-templates")]
|
||||
[Route("api/report-designer/templates")]
|
||||
public class ReportTemplatesController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
@@ -0,0 +1,471 @@
|
||||
using Zentral.API.Services.Reports;
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Apps.ReportDesigner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/report-designer/reports")]
|
||||
public class ReportsController : ControllerBase
|
||||
{
|
||||
private readonly ReportGeneratorService _reportGenerator;
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly SchemaDiscoveryService _schemaDiscovery;
|
||||
|
||||
public ReportsController(ReportGeneratorService reportGenerator, ZentralDbContext context, SchemaDiscoveryService schemaDiscovery)
|
||||
{
|
||||
_reportGenerator = reportGenerator;
|
||||
_context = context;
|
||||
_schemaDiscovery = schemaDiscovery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera un PDF da un template con i dati forniti
|
||||
/// </summary>
|
||||
[HttpPost("generate")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateReportRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pdf = await _reportGenerator.GeneratePdfAsync(request.TemplateId, request.DataContext);
|
||||
return File(pdf, "application/pdf", "report.pdf");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest($"Error generating report: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera il PDF di un evento usando il template predefinito o specificato
|
||||
/// </summary>
|
||||
[HttpGet("evento/{eventoId}")]
|
||||
public async Task<IActionResult> GenerateEvento(int eventoId, [FromQuery] int? templateId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pdf = await _reportGenerator.GenerateEventoPdfAsync(eventoId, templateId);
|
||||
return File(pdf, "application/pdf", $"evento_{eventoId}.pdf");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest($"Error generating report: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug endpoint to test binding resolution
|
||||
/// </summary>
|
||||
[HttpPost("debug-binding")]
|
||||
public async Task<IActionResult> DebugBinding([FromBody] DebugBindingRequest request)
|
||||
{
|
||||
var dataContext = await BuildDataContextAsync(request.DataSources);
|
||||
|
||||
var results = new Dictionary<string, object?>();
|
||||
results["dataContextKeys"] = dataContext.Keys.ToList();
|
||||
|
||||
foreach (var kvp in dataContext)
|
||||
{
|
||||
var type = kvp.Value?.GetType();
|
||||
results[$"dataset_{kvp.Key}_type"] = type?.Name;
|
||||
|
||||
// Try to get the property
|
||||
if (kvp.Value != null && !string.IsNullOrEmpty(request.PropertyName))
|
||||
{
|
||||
var prop = type?.GetProperty(request.PropertyName,
|
||||
System.Reflection.BindingFlags.Public |
|
||||
System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.IgnoreCase);
|
||||
|
||||
results[$"dataset_{kvp.Key}_property_found"] = prop != null;
|
||||
if (prop != null)
|
||||
{
|
||||
var value = prop.GetValue(kvp.Value);
|
||||
results[$"dataset_{kvp.Key}_value"] = value?.ToString();
|
||||
results[$"dataset_{kvp.Key}_value_type"] = value?.GetType().Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Genera un'anteprima del PDF con dati reali
|
||||
/// </summary>
|
||||
[HttpPost("preview")]
|
||||
public async Task<IActionResult> Preview([FromBody] PreviewReportRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataContext = await BuildDataContextAsync(request.DataSources);
|
||||
var pdf = await _reportGenerator.GeneratePdfAsync(request.TemplateId, dataContext);
|
||||
return File(pdf, "application/pdf");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return NotFound(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest($"Error generating preview: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista dei tipi di dataset disponibili (inclusi Virtual Dataset)
|
||||
/// </summary>
|
||||
[HttpGet("datasets")]
|
||||
public async Task<ActionResult<List<DatasetTypeDto>>> GetAvailableDatasets()
|
||||
{
|
||||
var datasets = _schemaDiscovery.GetAvailableDatasets();
|
||||
|
||||
// Aggiungi Virtual Dataset dal database
|
||||
var virtualDatasets = await _context.VirtualDatasets
|
||||
.Where(vd => vd.Attivo)
|
||||
.OrderBy(vd => vd.DisplayName)
|
||||
.Select(vd => new DatasetTypeDto
|
||||
{
|
||||
Id = $"virtual:{vd.Nome}", // Prefisso per identificare i virtual dataset
|
||||
Name = vd.DisplayName,
|
||||
Description = vd.Descrizione ?? "Dataset virtuale personalizzato",
|
||||
Icon = vd.Icon,
|
||||
Category = vd.Categoria,
|
||||
IsVirtual = true
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
datasets.AddRange(virtualDatasets);
|
||||
|
||||
return datasets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le categorie dei dataset (incluse quelle dei Virtual Dataset)
|
||||
/// </summary>
|
||||
[HttpGet("datasets/categories")]
|
||||
public async Task<ActionResult<List<string>>> GetDatasetCategories()
|
||||
{
|
||||
var datasets = _schemaDiscovery.GetAvailableDatasets();
|
||||
var baseCategories = datasets.Select(d => d.Category).Distinct().ToList();
|
||||
|
||||
// Aggiungi categorie dai Virtual Dataset
|
||||
var virtualCategories = await _context.VirtualDatasets
|
||||
.Where(vd => vd.Attivo)
|
||||
.Select(vd => vd.Categoria)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cat in virtualCategories)
|
||||
{
|
||||
if (!baseCategories.Contains(cat))
|
||||
baseCategories.Add(cat);
|
||||
}
|
||||
|
||||
baseCategories.Sort();
|
||||
|
||||
return baseCategories;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene lo schema dei dati per un dataset (inclusi Virtual Dataset)
|
||||
/// </summary>
|
||||
[HttpGet("schema/{datasetId}")]
|
||||
public async Task<ActionResult<DataSchemaDto>> GetSchema(string datasetId)
|
||||
{
|
||||
// Check se è un Virtual Dataset
|
||||
if (datasetId.StartsWith("virtual:"))
|
||||
{
|
||||
var virtualName = datasetId.Substring("virtual:".Length);
|
||||
var schema = await GetVirtualDatasetSchemaAsync(virtualName);
|
||||
if (schema == null)
|
||||
return NotFound($"Virtual Dataset '{virtualName}' not found");
|
||||
return schema;
|
||||
}
|
||||
|
||||
var staticSchema = _schemaDiscovery.GetSchema(datasetId);
|
||||
if (staticSchema == null)
|
||||
return NotFound($"Dataset '{datasetId}' not found");
|
||||
return staticSchema;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene la lista delle entità disponibili per un dataset (per la preview)
|
||||
/// </summary>
|
||||
[HttpGet("datasets/{datasetId}/entities")]
|
||||
public async Task<ActionResult<List<EntityListItemDto>>> GetEntitiesForDataset(
|
||||
string datasetId,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] int offset = 0)
|
||||
{
|
||||
// Virtual Dataset - restituisce le entità del dataset primario
|
||||
if (datasetId.StartsWith("virtual:"))
|
||||
{
|
||||
var virtualName = datasetId.Substring("virtual:".Length);
|
||||
return await GetVirtualDatasetEntities(virtualName, search, limit, offset);
|
||||
}
|
||||
|
||||
return await _schemaDiscovery.GetEntities(datasetId, search, limit, offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conta le entità disponibili per un dataset
|
||||
/// </summary>
|
||||
[HttpGet("datasets/{datasetId}/count")]
|
||||
public async Task<ActionResult<int>> GetEntityCount(string datasetId, [FromQuery] string? search = null)
|
||||
{
|
||||
return await _schemaDiscovery.CountEntities(datasetId, search);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<Dictionary<string, object>> BuildDataContextAsync(List<DataSourceSelection> dataSources)
|
||||
{
|
||||
var context = new Dictionary<string, object>();
|
||||
|
||||
foreach (var ds in dataSources)
|
||||
{
|
||||
object? data;
|
||||
|
||||
// Check se è un Virtual Dataset
|
||||
if (ds.DatasetId.StartsWith("virtual:"))
|
||||
{
|
||||
var virtualName = ds.DatasetId.Substring("virtual:".Length);
|
||||
data = await LoadVirtualDatasetAsync(virtualName, ds.EntityId);
|
||||
}
|
||||
else
|
||||
{
|
||||
data = await LoadEntityDataAsync(ds.DatasetId, ds.EntityId);
|
||||
}
|
||||
|
||||
if (data != null)
|
||||
{
|
||||
// Usa l'alias se fornito, altrimenti usa l'ID del dataset
|
||||
var key = ds.Alias ?? ds.DatasetId;
|
||||
// Per i virtual dataset, rimuovi il prefisso "virtual:" dalla chiave
|
||||
if (key.StartsWith("virtual:"))
|
||||
key = key.Substring("virtual:".Length);
|
||||
context[key] = data;
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private async Task<object?> LoadEntityDataAsync(string datasetId, int entityId)
|
||||
{
|
||||
return await _schemaDiscovery.LoadEntity(datasetId, entityId);
|
||||
}
|
||||
|
||||
#region Virtual Dataset Support
|
||||
|
||||
/// <summary>
|
||||
/// Genera lo schema per un Virtual Dataset basato sulla sua configurazione
|
||||
/// </summary>
|
||||
private async Task<DataSchemaDto?> GetVirtualDatasetSchemaAsync(string virtualName)
|
||||
{
|
||||
var virtualDataset = await _context.VirtualDatasets
|
||||
.FirstOrDefaultAsync(vd => vd.Nome == virtualName && vd.Attivo);
|
||||
|
||||
if (virtualDataset == null) return null;
|
||||
|
||||
var config = System.Text.Json.JsonSerializer.Deserialize<VirtualDatasetConfiguration>(
|
||||
virtualDataset.ConfigurationJson,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }
|
||||
);
|
||||
|
||||
if (config == null) return null;
|
||||
|
||||
var fields = new List<DataFieldDto>();
|
||||
|
||||
// Se ci sono OutputFields definiti, usa quelli
|
||||
if (config.OutputFields.Any(f => f.Included))
|
||||
{
|
||||
foreach (var outputField in config.OutputFields.Where(f => f.Included).OrderBy(f => f.Order))
|
||||
{
|
||||
var source = config.Sources.FirstOrDefault(s => s.Id == outputField.SourceId);
|
||||
if (source == null) continue;
|
||||
|
||||
// Ottieni lo schema del dataset sorgente per determinare il tipo
|
||||
var sourceSchema = _schemaDiscovery.GetSchema(source.DatasetId);
|
||||
var sourceField = sourceSchema?.Fields.FirstOrDefault(f =>
|
||||
f.Name.Equals(outputField.FieldName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
fields.Add(new DataFieldDto
|
||||
{
|
||||
Name = outputField.Alias ?? $"{source.Alias}.{outputField.FieldName}",
|
||||
Label = outputField.Label ?? sourceField?.Label ?? outputField.FieldName,
|
||||
Type = sourceField?.Type ?? "string",
|
||||
Group = outputField.Group ?? source.Alias
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Se non ci sono OutputFields, includi tutti i campi di tutte le sorgenti
|
||||
foreach (var source in config.Sources)
|
||||
{
|
||||
var sourceSchema = _schemaDiscovery.GetSchema(source.DatasetId);
|
||||
if (sourceSchema == null) continue;
|
||||
|
||||
foreach (var field in sourceSchema.Fields)
|
||||
{
|
||||
fields.Add(new DataFieldDto
|
||||
{
|
||||
Name = $"{source.Alias}.{field.Name}",
|
||||
Label = $"{source.Alias} - {field.Label}",
|
||||
Type = field.Type,
|
||||
Group = source.Alias
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new DataSchemaDto
|
||||
{
|
||||
EntityType = virtualDataset.DisplayName,
|
||||
DatasetId = $"virtual:{virtualDataset.Nome}",
|
||||
Fields = fields,
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le entità disponibili per un Virtual Dataset (basato sul dataset primario)
|
||||
/// </summary>
|
||||
private async Task<List<EntityListItemDto>> GetVirtualDatasetEntities(string virtualName, string? search, int limit, int offset)
|
||||
{
|
||||
var virtualDataset = await _context.VirtualDatasets
|
||||
.FirstOrDefaultAsync(vd => vd.Nome == virtualName && vd.Attivo);
|
||||
|
||||
if (virtualDataset == null) return new List<EntityListItemDto>();
|
||||
|
||||
var config = System.Text.Json.JsonSerializer.Deserialize<VirtualDatasetConfiguration>(
|
||||
virtualDataset.ConfigurationJson,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }
|
||||
);
|
||||
|
||||
if (config == null) return new List<EntityListItemDto>();
|
||||
|
||||
// Trova il dataset primario
|
||||
var primarySource = config.Sources.FirstOrDefault(s => s.IsPrimary)
|
||||
?? config.Sources.FirstOrDefault();
|
||||
|
||||
if (primarySource == null) return new List<EntityListItemDto>();
|
||||
|
||||
// Restituisce le entità del dataset primario
|
||||
return await _schemaDiscovery.GetEntities(primarySource.DatasetId, search, limit, offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carica i dati per un Virtual Dataset applicando JOIN e filtri
|
||||
/// </summary>
|
||||
private async Task<object?> LoadVirtualDatasetAsync(string virtualName, int primaryEntityId)
|
||||
{
|
||||
var virtualDataset = await _context.VirtualDatasets
|
||||
.FirstOrDefaultAsync(vd => vd.Nome == virtualName && vd.Attivo);
|
||||
|
||||
if (virtualDataset == null) return null;
|
||||
|
||||
var config = System.Text.Json.JsonSerializer.Deserialize<VirtualDatasetConfiguration>(
|
||||
virtualDataset.ConfigurationJson,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }
|
||||
);
|
||||
|
||||
if (config == null) return null;
|
||||
|
||||
// Carica tutti i dataset sorgente
|
||||
var loadedData = new Dictionary<string, object?>();
|
||||
|
||||
foreach (var source in config.Sources)
|
||||
{
|
||||
if (source.IsPrimary)
|
||||
{
|
||||
// Carica l'entità primaria
|
||||
loadedData[source.Id] = await LoadEntityDataAsync(source.DatasetId, primaryEntityId);
|
||||
}
|
||||
}
|
||||
|
||||
// Applica le relazioni per caricare i dati correlati
|
||||
foreach (var relationship in config.Relationships)
|
||||
{
|
||||
var fromData = loadedData.GetValueOrDefault(relationship.FromSourceId);
|
||||
if (fromData == null) continue;
|
||||
|
||||
var toSource = config.Sources.FirstOrDefault(s => s.Id == relationship.ToSourceId);
|
||||
if (toSource == null) continue;
|
||||
|
||||
// Ottieni il valore della chiave esterna
|
||||
var fromKeyValue = GetPropertyValue(fromData, relationship.FromField);
|
||||
if (fromKeyValue == null) continue;
|
||||
|
||||
// Carica l'entità correlata
|
||||
if (int.TryParse(fromKeyValue.ToString(), out int relatedId))
|
||||
{
|
||||
loadedData[relationship.ToSourceId] = await LoadEntityDataAsync(toSource.DatasetId, relatedId);
|
||||
}
|
||||
}
|
||||
|
||||
// Costruisci l'oggetto risultante con tutti i dati
|
||||
var result = new Dictionary<string, object?>();
|
||||
|
||||
foreach (var source in config.Sources)
|
||||
{
|
||||
var data = loadedData.GetValueOrDefault(source.Id);
|
||||
if (data != null)
|
||||
{
|
||||
result[source.Alias] = data;
|
||||
}
|
||||
}
|
||||
|
||||
// Se ci sono OutputFields, costruisci un oggetto piatto
|
||||
if (config.OutputFields.Any(f => f.Included))
|
||||
{
|
||||
var flatResult = new Dictionary<string, object?>();
|
||||
|
||||
foreach (var outputField in config.OutputFields.Where(f => f.Included))
|
||||
{
|
||||
var source = config.Sources.FirstOrDefault(s => s.Id == outputField.SourceId);
|
||||
if (source == null) continue;
|
||||
|
||||
var sourceData = loadedData.GetValueOrDefault(outputField.SourceId);
|
||||
if (sourceData == null) continue;
|
||||
|
||||
var value = GetPropertyValue(sourceData, outputField.FieldName);
|
||||
var fieldName = outputField.Alias ?? $"{source.Alias}_{outputField.FieldName}";
|
||||
flatResult[fieldName] = value;
|
||||
}
|
||||
|
||||
return flatResult;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static object? GetPropertyValue(object obj, string propertyName)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var prop = type.GetProperty(propertyName,
|
||||
System.Reflection.BindingFlags.Public |
|
||||
System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.IgnoreCase);
|
||||
return prop?.GetValue(obj);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
}
|
||||
|
||||
// DTOs moved to AprtModels.cs
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Sales.Dtos;
|
||||
using Zentral.API.Modules.Sales.Services;
|
||||
using Zentral.API.Apps.Sales.Dtos;
|
||||
using Zentral.API.Apps.Sales.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Sales.Controllers;
|
||||
namespace Zentral.API.Apps.Sales.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/sales/orders")]
|
||||
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using Zentral.Domain.Entities.Sales;
|
||||
|
||||
namespace Zentral.API.Modules.Sales.Dtos;
|
||||
namespace Zentral.API.Apps.Sales.Dtos;
|
||||
|
||||
public class SalesOrderDto
|
||||
{
|
||||
@@ -2,15 +2,15 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Zentral.API.Modules.Sales.Dtos;
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Sales.Dtos;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.API.Services;
|
||||
using Zentral.Domain.Entities.Sales;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.API.Modules.Sales.Services;
|
||||
namespace Zentral.API.Apps.Sales.Services;
|
||||
|
||||
public class SalesService
|
||||
{
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
namespace Zentral.API.Apps.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione delle partite/lotti
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
namespace Zentral.API.Apps.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione degli inventari fisici
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
namespace Zentral.API.Apps.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione dei seriali/matricole
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
namespace Zentral.API.Apps.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione delle giacenze e valorizzazione
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
namespace Zentral.API.Apps.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione dei movimenti di magazzino
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
namespace Zentral.API.Apps.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione degli articoli di magazzino
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
namespace Zentral.API.Apps.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione delle categorie articoli
|
||||
@@ -1,8 +1,8 @@
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Controllers;
|
||||
namespace Zentral.API.Apps.Warehouse.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione dei magazzini
|
||||
@@ -1,6 +1,6 @@
|
||||
using Zentral.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Services;
|
||||
namespace Zentral.API.Apps.Warehouse.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interfaccia servizio principale per il modulo Magazzino
|
||||
@@ -28,6 +28,10 @@ public interface IWarehouseService
|
||||
Task<WarehouseArticleCategory> UpdateCategoryAsync(WarehouseArticleCategory category);
|
||||
Task DeleteCategoryAsync(int id);
|
||||
|
||||
// ===============================================
|
||||
// GRUPPI MERCEOLOGICI
|
||||
// ===============================================
|
||||
|
||||
// ===============================================
|
||||
// MAGAZZINI
|
||||
// ===============================================
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using Zentral.API.Hubs;
|
||||
|
||||
namespace Zentral.API.Modules.Warehouse.Services;
|
||||
namespace Zentral.API.Apps.Warehouse.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementazione del servizio principale per il modulo Magazzino
|
||||
@@ -60,6 +60,7 @@ public class WarehouseService : IWarehouseService
|
||||
if (filter.CategoryId.HasValue)
|
||||
query = query.Where(a => a.CategoryId == filter.CategoryId);
|
||||
|
||||
|
||||
if (filter.IsActive.HasValue)
|
||||
query = query.Where(a => a.IsActive == filter.IsActive);
|
||||
|
||||
@@ -336,6 +337,7 @@ public class WarehouseService : IWarehouseService
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region Magazzini
|
||||
|
||||
public async Task<List<WarehouseLocation>> GetWarehousesAsync(bool includeInactive = false)
|
||||
@@ -5,87 +5,87 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Zentral.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller per la gestione dei moduli applicativi e delle subscription
|
||||
/// Controller per la gestione delle applicazioni e delle subscription
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ModulesController : ControllerBase
|
||||
public class AppsController : ControllerBase
|
||||
{
|
||||
private readonly ModuleService _moduleService;
|
||||
private readonly ILogger<ModulesController> _logger;
|
||||
private readonly AppService _appService;
|
||||
private readonly ILogger<AppsController> _logger;
|
||||
|
||||
public ModulesController(ModuleService moduleService, ILogger<ModulesController> logger)
|
||||
public AppsController(AppService appService, ILogger<AppsController> logger)
|
||||
{
|
||||
_moduleService = moduleService;
|
||||
_appService = appService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutti i moduli disponibili con stato subscription
|
||||
/// Ottiene tutte le applicazioni disponibili con stato subscription
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<ModuleDto>>> GetAllModules()
|
||||
public async Task<ActionResult<List<AppDto>>> GetAllApps()
|
||||
{
|
||||
var modules = await _moduleService.GetAllModulesAsync();
|
||||
return Ok(modules.Select(MapToDto).ToList());
|
||||
var apps = await _appService.GetAllAppsAsync();
|
||||
return Ok(apps.Select(MapToDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene solo i moduli attivi (per costruzione menu)
|
||||
/// Ottiene solo le applicazioni attive (per costruzione menu)
|
||||
/// </summary>
|
||||
[HttpGet("active")]
|
||||
public async Task<ActionResult<List<ModuleDto>>> GetActiveModules()
|
||||
public async Task<ActionResult<List<AppDto>>> GetActiveApps()
|
||||
{
|
||||
var modules = await _moduleService.GetActiveModulesAsync();
|
||||
return Ok(modules.Select(MapToDto).ToList());
|
||||
var apps = await _appService.GetActiveAppsAsync();
|
||||
return Ok(apps.Select(MapToDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un modulo specifico per codice
|
||||
/// Ottiene un'applicazione specifica per codice
|
||||
/// </summary>
|
||||
[HttpGet("{code}")]
|
||||
public async Task<ActionResult<ModuleDto>> GetModule(string code)
|
||||
public async Task<ActionResult<AppDto>> GetApp(string code)
|
||||
{
|
||||
var module = await _moduleService.GetModuleByCodeAsync(code);
|
||||
if (module == null)
|
||||
return NotFound(new { message = $"Modulo '{code}' non trovato" });
|
||||
var app = await _appService.GetAppByCodeAsync(code);
|
||||
if (app == null)
|
||||
return NotFound(new { message = $"Applicazione '{code}' non trovata" });
|
||||
|
||||
return Ok(MapToDto(module));
|
||||
return Ok(MapToDto(app));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se un modulo è abilitato
|
||||
/// Verifica se un'applicazione è abilitata
|
||||
/// </summary>
|
||||
[HttpGet("{code}/enabled")]
|
||||
public async Task<ActionResult<ModuleStatusDto>> IsModuleEnabled(string code)
|
||||
public async Task<ActionResult<AppStatusDto>> IsAppEnabled(string code)
|
||||
{
|
||||
var module = await _moduleService.GetModuleByCodeAsync(code);
|
||||
if (module == null)
|
||||
return NotFound(new { message = $"Modulo '{code}' non trovato" });
|
||||
var app = await _appService.GetAppByCodeAsync(code);
|
||||
if (app == null)
|
||||
return NotFound(new { message = $"Applicazione '{code}' non trovata" });
|
||||
|
||||
var isEnabled = await _moduleService.IsModuleEnabledAsync(code);
|
||||
var hasValidSubscription = await _moduleService.HasValidSubscriptionAsync(code);
|
||||
var isEnabled = await _appService.IsAppEnabledAsync(code);
|
||||
var hasValidSubscription = await _appService.HasValidSubscriptionAsync(code);
|
||||
|
||||
return Ok(new ModuleStatusDto
|
||||
return Ok(new AppStatusDto
|
||||
{
|
||||
Code = code,
|
||||
IsEnabled = isEnabled,
|
||||
HasValidSubscription = hasValidSubscription,
|
||||
IsCore = module.IsCore,
|
||||
DaysRemaining = module.Subscription?.GetDaysRemaining(),
|
||||
IsExpiringSoon = module.Subscription?.IsExpiringSoon() ?? false
|
||||
IsCore = app.IsCore,
|
||||
DaysRemaining = app.Subscription?.GetDaysRemaining(),
|
||||
IsExpiringSoon = app.Subscription?.IsExpiringSoon() ?? false
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attiva un modulo
|
||||
/// Attiva un'applicazione
|
||||
/// </summary>
|
||||
[HttpPut("{code}/enable")]
|
||||
public async Task<ActionResult<SubscriptionDto>> EnableModule(string code, [FromBody] EnableModuleRequest request)
|
||||
public async Task<ActionResult<AppSubscriptionDto>> EnableApp(string code, [FromBody] EnableAppRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var subscription = await _moduleService.EnableModuleAsync(
|
||||
var subscription = await _appService.EnableAppAsync(
|
||||
code,
|
||||
request.SubscriptionType,
|
||||
request.StartDate,
|
||||
@@ -107,15 +107,15 @@ public class ModulesController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disattiva un modulo
|
||||
/// Disattiva un'applicazione
|
||||
/// </summary>
|
||||
[HttpPut("{code}/disable")]
|
||||
public async Task<ActionResult> DisableModule(string code)
|
||||
public async Task<ActionResult> DisableApp(string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _moduleService.DisableModuleAsync(code);
|
||||
return Ok(new { message = $"Modulo '{code}' disattivato" });
|
||||
await _appService.DisableAppAsync(code);
|
||||
return Ok(new { message = $"Applicazione '{code}' disattivata" });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -131,21 +131,21 @@ public class ModulesController : ControllerBase
|
||||
/// Ottiene tutte le subscription
|
||||
/// </summary>
|
||||
[HttpGet("subscriptions")]
|
||||
public async Task<ActionResult<List<SubscriptionDto>>> GetAllSubscriptions()
|
||||
public async Task<ActionResult<List<AppSubscriptionDto>>> GetAllAppSubscriptions()
|
||||
{
|
||||
var subscriptions = await _moduleService.GetAllSubscriptionsAsync();
|
||||
var subscriptions = await _appService.GetAllSubscriptionsAsync();
|
||||
return Ok(subscriptions.Select(MapSubscriptionToDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna la subscription di un modulo
|
||||
/// Aggiorna la subscription di un'applicazione
|
||||
/// </summary>
|
||||
[HttpPut("{code}/subscription")]
|
||||
public async Task<ActionResult<SubscriptionDto>> UpdateSubscription(string code, [FromBody] UpdateSubscriptionRequest request)
|
||||
public async Task<ActionResult<AppSubscriptionDto>> UpdateAppSubscription(string code, [FromBody] UpdateAppSubscriptionRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var subscription = await _moduleService.UpdateSubscriptionAsync(
|
||||
var subscription = await _appService.UpdateSubscriptionAsync(
|
||||
code,
|
||||
request.SubscriptionType,
|
||||
request.EndDate,
|
||||
@@ -165,14 +165,14 @@ public class ModulesController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rinnova la subscription di un modulo
|
||||
/// Rinnova la subscription di un'applicazione
|
||||
/// </summary>
|
||||
[HttpPost("{code}/subscription/renew")]
|
||||
public async Task<ActionResult<SubscriptionDto>> RenewSubscription(string code, [FromBody] RenewSubscriptionRequest? request = null)
|
||||
public async Task<ActionResult<AppSubscriptionDto>> RenewAppSubscription(string code, [FromBody] RenewAppSubscriptionRequest? request = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var subscription = await _moduleService.RenewSubscriptionAsync(code, request?.PaidPrice);
|
||||
var subscription = await _appService.RenewSubscriptionAsync(code, request?.PaidPrice);
|
||||
return Ok(MapSubscriptionToDto(subscription));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
@@ -186,77 +186,77 @@ public class ModulesController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene i moduli in scadenza
|
||||
/// Ottiene le applicazioni in scadenza
|
||||
/// </summary>
|
||||
[HttpGet("expiring")]
|
||||
public async Task<ActionResult<List<ModuleDto>>> GetExpiringModules([FromQuery] int daysThreshold = 30)
|
||||
public async Task<ActionResult<List<AppDto>>> GetExpiringApps([FromQuery] int daysThreshold = 30)
|
||||
{
|
||||
var modules = await _moduleService.GetExpiringModulesAsync(daysThreshold);
|
||||
return Ok(modules.Select(MapToDto).ToList());
|
||||
var apps = await _appService.GetExpiringAppsAsync(daysThreshold);
|
||||
return Ok(apps.Select(MapToDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inizializza i moduli di default (per setup iniziale)
|
||||
/// Inizializza le applicazioni di default (per setup iniziale)
|
||||
/// </summary>
|
||||
[HttpPost("seed")]
|
||||
public async Task<ActionResult> SeedDefaultModules()
|
||||
public async Task<ActionResult> SeedDefaultApps()
|
||||
{
|
||||
await _moduleService.SeedDefaultModulesAsync();
|
||||
return Ok(new { message = "Moduli di default inizializzati" });
|
||||
await _appService.SeedDefaultAppsAsync();
|
||||
return Ok(new { message = "Applicazioni di default inizializzate" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forza il controllo delle subscription scadute
|
||||
/// </summary>
|
||||
[HttpPost("check-expired")]
|
||||
public async Task<ActionResult> CheckExpiredSubscriptions()
|
||||
public async Task<ActionResult> CheckExpiredAppSubscriptions()
|
||||
{
|
||||
var count = await _moduleService.CheckExpiredSubscriptionsAsync();
|
||||
return Ok(new { message = $"Controllate le subscription, {count} moduli disattivati per scadenza" });
|
||||
var count = await _appService.CheckExpiredSubscriptionsAsync();
|
||||
return Ok(new { message = $"Controllate le subscription, {count} applicazioni disattivate per scadenza" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalida la cache dei moduli
|
||||
/// Invalida la cache delle applicazioni
|
||||
/// </summary>
|
||||
[HttpPost("invalidate-cache")]
|
||||
public ActionResult InvalidateCache()
|
||||
public ActionResult InvalidateAppsCache()
|
||||
{
|
||||
_moduleService.InvalidateCache();
|
||||
return Ok(new { message = "Cache moduli invalidata" });
|
||||
_appService.InvalidateCache();
|
||||
return Ok(new { message = "Cache applicazioni invalidata" });
|
||||
}
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static ModuleDto MapToDto(AppModule module)
|
||||
private static AppDto MapToDto(App app)
|
||||
{
|
||||
return new ModuleDto
|
||||
return new AppDto
|
||||
{
|
||||
Id = module.Id,
|
||||
Code = module.Code,
|
||||
Name = module.Name,
|
||||
Description = module.Description,
|
||||
Icon = module.Icon,
|
||||
BasePrice = module.BasePrice,
|
||||
MonthlyPrice = module.GetMonthlyPrice(),
|
||||
MonthlyMultiplier = module.MonthlyMultiplier,
|
||||
SortOrder = module.SortOrder,
|
||||
IsCore = module.IsCore,
|
||||
Dependencies = module.GetDependencies().ToList(),
|
||||
RoutePath = module.RoutePath,
|
||||
IsAvailable = module.IsAvailable,
|
||||
IsEnabled = module.IsCore || (module.Subscription?.IsValid() ?? false),
|
||||
Subscription = module.Subscription != null ? MapSubscriptionToDto(module.Subscription) : null
|
||||
Id = app.Id,
|
||||
Code = app.Code,
|
||||
Name = app.Name,
|
||||
Description = app.Description,
|
||||
Icon = app.Icon,
|
||||
BasePrice = app.BasePrice,
|
||||
MonthlyPrice = app.GetMonthlyPrice(),
|
||||
MonthlyMultiplier = app.MonthlyMultiplier,
|
||||
SortOrder = app.SortOrder,
|
||||
IsCore = app.IsCore,
|
||||
Dependencies = app.GetDependencies().ToList(),
|
||||
RoutePath = app.RoutePath,
|
||||
IsAvailable = app.IsAvailable,
|
||||
IsEnabled = app.IsCore || ((app.Subscription?.IsEnabled ?? false) && (app.Subscription?.IsValid() ?? false)),
|
||||
Subscription = app.Subscription != null ? MapSubscriptionToDto(app.Subscription) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static SubscriptionDto MapSubscriptionToDto(ModuleSubscription subscription)
|
||||
private static AppSubscriptionDto MapSubscriptionToDto(AppSubscription subscription)
|
||||
{
|
||||
return new SubscriptionDto
|
||||
return new AppSubscriptionDto
|
||||
{
|
||||
Id = subscription.Id,
|
||||
ModuleId = subscription.ModuleId,
|
||||
ModuleCode = subscription.Module?.Code,
|
||||
ModuleName = subscription.Module?.Name,
|
||||
AppId = subscription.AppId,
|
||||
AppCode = subscription.App?.Code,
|
||||
AppName = subscription.App?.Name,
|
||||
IsEnabled = subscription.IsEnabled,
|
||||
SubscriptionType = subscription.SubscriptionType,
|
||||
SubscriptionTypeName = subscription.SubscriptionType.ToString(),
|
||||
@@ -277,7 +277,7 @@ public class ModulesController : ControllerBase
|
||||
|
||||
#region DTOs
|
||||
|
||||
public class ModuleDto
|
||||
public class AppDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
@@ -293,15 +293,15 @@ public class ModuleDto
|
||||
public string? RoutePath { get; set; }
|
||||
public bool IsAvailable { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public SubscriptionDto? Subscription { get; set; }
|
||||
public AppSubscriptionDto? Subscription { get; set; }
|
||||
}
|
||||
|
||||
public class SubscriptionDto
|
||||
public class AppSubscriptionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ModuleId { get; set; }
|
||||
public string? ModuleCode { get; set; }
|
||||
public string? ModuleName { get; set; }
|
||||
public int AppId { get; set; }
|
||||
public string? AppCode { get; set; }
|
||||
public string? AppName { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public SubscriptionType SubscriptionType { get; set; }
|
||||
public string SubscriptionTypeName { get; set; } = string.Empty;
|
||||
@@ -316,7 +316,7 @@ public class SubscriptionDto
|
||||
public bool IsExpiringSoon { get; set; }
|
||||
}
|
||||
|
||||
public class ModuleStatusDto
|
||||
public class AppStatusDto
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public bool IsEnabled { get; set; }
|
||||
@@ -326,7 +326,7 @@ public class ModuleStatusDto
|
||||
public bool IsExpiringSoon { get; set; }
|
||||
}
|
||||
|
||||
public class EnableModuleRequest
|
||||
public class EnableAppRequest
|
||||
{
|
||||
public SubscriptionType SubscriptionType { get; set; } = SubscriptionType.Annual;
|
||||
public DateTime? StartDate { get; set; }
|
||||
@@ -336,7 +336,7 @@ public class EnableModuleRequest
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateSubscriptionRequest
|
||||
public class UpdateAppSubscriptionRequest
|
||||
{
|
||||
public SubscriptionType? SubscriptionType { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
@@ -344,7 +344,7 @@ public class UpdateSubscriptionRequest
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class RenewSubscriptionRequest
|
||||
public class RenewAppSubscriptionRequest
|
||||
{
|
||||
public decimal? PaidPrice { get; set; }
|
||||
}
|
||||
@@ -24,7 +24,8 @@ public class ArticoliController : ControllerBase
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] int? tipoMaterialeId,
|
||||
[FromQuery] int? categoriaId,
|
||||
[FromQuery] bool? attivo)
|
||||
[FromQuery] bool? attivo,
|
||||
[FromQuery] TipoArticolo? tipo)
|
||||
{
|
||||
var query = _context.Articoli
|
||||
.Include(a => a.TipoMateriale)
|
||||
@@ -43,6 +44,9 @@ public class ArticoliController : ControllerBase
|
||||
if (attivo.HasValue)
|
||||
query = query.Where(a => a.Attivo == attivo.Value);
|
||||
|
||||
if (tipo.HasValue)
|
||||
query = query.Where(a => a.Tipo == tipo.Value);
|
||||
|
||||
return await query.OrderBy(a => a.Descrizione).ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public class ClientiController : ControllerBase
|
||||
{
|
||||
var cliente = await _context.Clienti
|
||||
.Include(c => c.Eventi)
|
||||
.Include(c => c.Contatti)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (cliente == null)
|
||||
@@ -99,4 +100,53 @@ public class ClientiController : ControllerBase
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Contatti Management
|
||||
[HttpGet("{id}/contatti")]
|
||||
public async Task<ActionResult<IEnumerable<ClienteContatto>>> GetContatti(int id)
|
||||
{
|
||||
var contatti = await _context.Contatti
|
||||
.Where(c => c.ClienteId == id)
|
||||
.OrderBy(c => c.Cognome).ThenBy(c => c.Nome)
|
||||
.ToListAsync();
|
||||
return contatti;
|
||||
}
|
||||
|
||||
[HttpPost("{id}/contatti")]
|
||||
public async Task<ActionResult<ClienteContatto>> CreateContatto(int id, ClienteContatto contatto)
|
||||
{
|
||||
if (id != contatto.ClienteId)
|
||||
contatto.ClienteId = id;
|
||||
|
||||
contatto.CreatedAt = DateTime.UtcNow;
|
||||
_context.Contatti.Add(contatto);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(contatto);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/contatti/{contattoId}")]
|
||||
public async Task<IActionResult> UpdateContatto(int id, int contattoId, ClienteContatto contatto)
|
||||
{
|
||||
if (id != contatto.ClienteId || contattoId != contatto.Id)
|
||||
return BadRequest();
|
||||
|
||||
contatto.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(contatto).State = EntityState.Modified;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/contatti/{contattoId}")]
|
||||
public async Task<IActionResult> DeleteContatto(int id, int contattoId)
|
||||
{
|
||||
var contatto = await _context.Contatti.FindAsync(contattoId);
|
||||
if (contatto == null || contatto.ClienteId != id)
|
||||
return NotFound();
|
||||
|
||||
_context.Contatti.Remove(contatto);
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
77
src/backend/Zentral.API/Controllers/DashboardController.cs
Normal file
77
src/backend/Zentral.API/Controllers/DashboardController.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Infrastructure.Data;
|
||||
|
||||
namespace Zentral.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
|
||||
public DashboardController(ZentralDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet("preference")]
|
||||
public async Task<ActionResult<DashboardPreferenceDto>> GetPreference()
|
||||
{
|
||||
// TODO: Implement proper user identification
|
||||
// For now, we'll use the first active user or a specific test user
|
||||
var user = await _context.Utenti.FirstOrDefaultAsync(u => u.Attivo);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound("No active user found");
|
||||
}
|
||||
|
||||
var preference = await _context.UserDashboardPreferences
|
||||
.FirstOrDefaultAsync(p => p.UtenteId == user.Id);
|
||||
|
||||
if (preference == null)
|
||||
{
|
||||
return Ok(new DashboardPreferenceDto { LayoutJson = "[]" });
|
||||
}
|
||||
|
||||
return Ok(new DashboardPreferenceDto { LayoutJson = preference.LayoutJson });
|
||||
}
|
||||
|
||||
[HttpPost("preference")]
|
||||
public async Task<ActionResult> SavePreference([FromBody] DashboardPreferenceDto dto)
|
||||
{
|
||||
// TODO: Implement proper user identification
|
||||
var user = await _context.Utenti.FirstOrDefaultAsync(u => u.Attivo);
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound("No active user found");
|
||||
}
|
||||
|
||||
var preference = await _context.UserDashboardPreferences
|
||||
.FirstOrDefaultAsync(p => p.UtenteId == user.Id);
|
||||
|
||||
if (preference == null)
|
||||
{
|
||||
preference = new UserDashboardPreference
|
||||
{
|
||||
UtenteId = user.Id,
|
||||
LayoutJson = dto.LayoutJson
|
||||
};
|
||||
_context.UserDashboardPreferences.Add(preference);
|
||||
}
|
||||
else
|
||||
{
|
||||
preference.LayoutJson = dto.LayoutJson;
|
||||
preference.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
public class DashboardPreferenceDto
|
||||
{
|
||||
public string LayoutJson { get; set; } = "[]";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Zentral.API.Services.Reports;
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -11,10 +12,12 @@ namespace Zentral.API.Controllers;
|
||||
public class VirtualDatasetsController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly SchemaDiscoveryService _schemaDiscovery;
|
||||
|
||||
public VirtualDatasetsController(ZentralDbContext context)
|
||||
public VirtualDatasetsController(ZentralDbContext context, SchemaDiscoveryService schemaDiscovery)
|
||||
{
|
||||
_context = context;
|
||||
_schemaDiscovery = schemaDiscovery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -364,11 +367,19 @@ public class VirtualDatasetsController : ControllerBase
|
||||
var source = config.Sources.FirstOrDefault(s => s.Id == outputField.SourceId);
|
||||
if (source == null) continue;
|
||||
|
||||
var sourceSchema = _schemaDiscovery.GetSchema(source.DatasetId);
|
||||
var fieldType = "string";
|
||||
if (sourceSchema != null)
|
||||
{
|
||||
var sourceField = sourceSchema.Fields.FirstOrDefault(f => f.Name.Equals(outputField.FieldName, StringComparison.OrdinalIgnoreCase));
|
||||
if (sourceField != null) fieldType = sourceField.Type;
|
||||
}
|
||||
|
||||
fields.Add(new DataFieldDto
|
||||
{
|
||||
Name = outputField.Alias ?? $"{source.Alias}.{outputField.FieldName}",
|
||||
Label = outputField.Label ?? outputField.FieldName,
|
||||
Type = "string", // TODO: determinare il tipo dal dataset sorgente
|
||||
Type = fieldType,
|
||||
Group = outputField.Group ?? source.Alias
|
||||
});
|
||||
}
|
||||
@@ -378,7 +389,7 @@ public class VirtualDatasetsController : ControllerBase
|
||||
// Altrimenti, includi tutti i campi da tutte le sorgenti
|
||||
foreach (var source in config.Sources)
|
||||
{
|
||||
var sourceSchema = GetBaseDatasetSchema(source.DatasetId);
|
||||
var sourceSchema = _schemaDiscovery.GetSchema(source.DatasetId);
|
||||
if (sourceSchema == null) continue;
|
||||
|
||||
foreach (var field in sourceSchema.Fields)
|
||||
@@ -426,91 +437,7 @@ public class VirtualDatasetsController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
private DataSchemaDto? GetBaseDatasetSchema(string datasetId)
|
||||
{
|
||||
// Riutilizza la logica di ReportsController per ottenere lo schema base
|
||||
// TODO: estrarre in un servizio condiviso
|
||||
return datasetId.ToLower() switch
|
||||
{
|
||||
"evento" => new DataSchemaDto
|
||||
{
|
||||
EntityType = "Evento",
|
||||
DatasetId = "evento",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID" },
|
||||
new() { Name = "codice", Type = "string", Label = "Codice" },
|
||||
new() { Name = "dataEvento", Type = "date", Label = "Data Evento" },
|
||||
new() { Name = "descrizione", Type = "string", Label = "Descrizione" },
|
||||
new() { Name = "stato", Type = "number", Label = "Stato" },
|
||||
new() { Name = "numeroOspiti", Type = "number", Label = "Numero Ospiti" },
|
||||
new() { Name = "costoTotale", Type = "currency", Label = "Costo Totale" },
|
||||
new() { Name = "clienteId", Type = "number", Label = "ID Cliente" },
|
||||
new() { Name = "locationId", Type = "number", Label = "ID Location" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
},
|
||||
"cliente" => new DataSchemaDto
|
||||
{
|
||||
EntityType = "Cliente",
|
||||
DatasetId = "cliente",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID" },
|
||||
new() { Name = "ragioneSociale", Type = "string", Label = "Ragione Sociale" },
|
||||
new() { Name = "indirizzo", Type = "string", Label = "Indirizzo" },
|
||||
new() { Name = "citta", Type = "string", Label = "Città" },
|
||||
new() { Name = "telefono", Type = "string", Label = "Telefono" },
|
||||
new() { Name = "email", Type = "string", Label = "Email" },
|
||||
new() { Name = "partitaIva", Type = "string", Label = "Partita IVA" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
},
|
||||
"location" => new DataSchemaDto
|
||||
{
|
||||
EntityType = "Location",
|
||||
DatasetId = "location",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID" },
|
||||
new() { Name = "nome", Type = "string", Label = "Nome" },
|
||||
new() { Name = "indirizzo", Type = "string", Label = "Indirizzo" },
|
||||
new() { Name = "citta", Type = "string", Label = "Città" },
|
||||
new() { Name = "distanzaKm", Type = "number", Label = "Distanza (km)" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
},
|
||||
"articolo" => new DataSchemaDto
|
||||
{
|
||||
EntityType = "Articolo",
|
||||
DatasetId = "articolo",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID" },
|
||||
new() { Name = "codice", Type = "string", Label = "Codice" },
|
||||
new() { Name = "descrizione", Type = "string", Label = "Descrizione" },
|
||||
new() { Name = "qtaDisponibile", Type = "number", Label = "Qtà Disponibile" },
|
||||
new() { Name = "categoriaId", Type = "number", Label = "ID Categoria" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
},
|
||||
"risorsa" => new DataSchemaDto
|
||||
{
|
||||
EntityType = "Risorsa",
|
||||
DatasetId = "risorsa",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID" },
|
||||
new() { Name = "nome", Type = "string", Label = "Nome" },
|
||||
new() { Name = "cognome", Type = "string", Label = "Cognome" },
|
||||
new() { Name = "telefono", Type = "string", Label = "Telefono" },
|
||||
new() { Name = "tipoRisorsaId", Type = "number", Label = "ID Tipo Risorsa" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.Domain.Entities.Training;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Zentral.API.Services;
|
||||
|
||||
namespace Zentral.API.Modules.Training.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/training")]
|
||||
public class TrainingController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly AppService _appService;
|
||||
|
||||
public TrainingController(ZentralDbContext context, IEmailSender emailSender, AppService appService)
|
||||
{
|
||||
_context = context;
|
||||
_emailSender = emailSender;
|
||||
_appService = appService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<TrainingRecord>>> GetTrainings(
|
||||
[FromQuery] int? clienteId,
|
||||
[FromQuery] int? articoloId,
|
||||
[FromQuery] bool? expiring)
|
||||
{
|
||||
var query = _context.TrainingRecords
|
||||
.Include(t => t.ClienteContatto)
|
||||
.ThenInclude(cc => cc.Cliente)
|
||||
.Include(t => t.Articolo)
|
||||
.AsQueryable();
|
||||
|
||||
if (clienteId.HasValue)
|
||||
query = query.Where(t => t.ClienteContatto.ClienteId == clienteId);
|
||||
|
||||
if (articoloId.HasValue)
|
||||
query = query.Where(t => t.ArticoloId == articoloId);
|
||||
|
||||
if (expiring.HasValue && expiring.Value)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var threshold = today.AddDays(30);
|
||||
query = query.Where(t => t.DataScadenza != null && t.DataScadenza <= threshold && t.DataScadenza >= today);
|
||||
}
|
||||
|
||||
return await query.OrderBy(t => t.DataScadenza).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<TrainingRecord>> GetTraining(int id)
|
||||
{
|
||||
var training = await _context.TrainingRecords
|
||||
.Include(t => t.ClienteContatto)
|
||||
.Include(t => t.Articolo)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
if (training == null)
|
||||
return NotFound();
|
||||
|
||||
return training;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<TrainingRecord>> CreateTraining(TrainingRecord training)
|
||||
{
|
||||
// Calculate expiration if needed logic suggests it, but usually passed by frontend or computed from course validity
|
||||
// If DataScadenza is null, try to calculate from Articolo
|
||||
|
||||
if (training.DataScadenza == null)
|
||||
{
|
||||
var articolo = await _context.Articoli.FindAsync(training.ArticoloId);
|
||||
if (articolo != null && articolo.GiorniValidita.HasValue)
|
||||
{
|
||||
training.DataScadenza = training.DataEsecuzione.AddDays(articolo.GiorniValidita.Value);
|
||||
}
|
||||
}
|
||||
|
||||
training.CreatedAt = DateTime.UtcNow;
|
||||
_context.TrainingRecords.Add(training);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(nameof(GetTraining), new { id = training.Id }, training);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateTraining(int id, TrainingRecord training)
|
||||
{
|
||||
if (id != training.Id)
|
||||
return BadRequest();
|
||||
|
||||
training.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(training).State = EntityState.Modified;
|
||||
|
||||
try
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
if (!await _context.TrainingRecords.AnyAsync(e => e.Id == id))
|
||||
return NotFound();
|
||||
throw;
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteTraining(int id)
|
||||
{
|
||||
var training = await _context.TrainingRecords.FindAsync(id);
|
||||
if (training == null)
|
||||
return NotFound();
|
||||
|
||||
_context.TrainingRecords.Remove(training);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("expiring")]
|
||||
public async Task<ActionResult<IEnumerable<TrainingRecord>>> GetExpiringTrainings()
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var threshold = today.AddDays(30);
|
||||
|
||||
// Return Expired ( < today) OR Expiring Soon ( between today and threshold )
|
||||
var records = await _context.TrainingRecords
|
||||
.Include(t => t.ClienteContatto)
|
||||
.Include(t => t.Articolo)
|
||||
.Where(t => t.DataScadenza != null && (t.DataScadenza <= threshold))
|
||||
.OrderBy(t => t.DataScadenza)
|
||||
.ToListAsync();
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
[HttpPost("{id}/attestato")]
|
||||
public async Task<IActionResult> UploadAttestato(int id, IFormFile file)
|
||||
{
|
||||
var training = await _context.TrainingRecords.FindAsync(id);
|
||||
if (training == null)
|
||||
return NotFound();
|
||||
|
||||
// Save file logic - For now saving to wwwroot/uploads or similar, or just keeping URL if using external storage
|
||||
// Assuming simple local storage for now
|
||||
|
||||
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "training");
|
||||
if (!Directory.Exists(uploadsFolder))
|
||||
Directory.CreateDirectory(uploadsFolder);
|
||||
|
||||
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
|
||||
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
training.AttestatoUrl = $"/uploads/training/{fileName}";
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new { url = training.AttestatoUrl });
|
||||
}
|
||||
|
||||
[HttpPost("{id}/notify")]
|
||||
public async Task<IActionResult> SendNotification(int id)
|
||||
{
|
||||
if (!await _appService.IsAppEnabledAsync("communications"))
|
||||
return BadRequest(new { message = "Il modulo Comunicazioni non è attivo. Impossibile inviare email." });
|
||||
|
||||
var training = await _context.TrainingRecords
|
||||
.Include(t => t.ClienteContatto)
|
||||
.Include(t => t.Articolo)
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
if (training == null)
|
||||
return NotFound();
|
||||
|
||||
var emailSubject = $"Scadenza Formazione: {training.Articolo?.Descrizione}";
|
||||
var emailBody = $@"
|
||||
<h3>Avviso Scadenza Formazione</h3>
|
||||
<p>Gentile {training.ClienteContatto?.Nome} {training.ClienteContatto?.Cognome},</p>
|
||||
<p>Si ricorda che la formazione <strong>{training.Articolo?.Descrizione}</strong> effettuata il {training.DataEsecuzione:dd/MM/yyyy} è in scadenza il <strong>{training.DataScadenza:dd/MM/yyyy}</strong>.</p>
|
||||
<p>Si prega di provvedere al rinnovo.</p>
|
||||
<br>
|
||||
<p>Cordiali saluti,<br>Team Formazione</p>
|
||||
";
|
||||
|
||||
if (!string.IsNullOrEmpty(training.ClienteContatto?.Email))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _emailSender.SendEmailAsync(training.ClienteContatto.Email, emailSubject, emailBody);
|
||||
return Ok(new { message = $"Notifica inviata a {training.ClienteContatto.Email}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = $"Errore invio email: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
return BadRequest(new { message = "Email contatto non presente" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.API.Modules.Training.Services;
|
||||
using Zentral.Domain.Entities.Training;
|
||||
using Zentral.Infrastructure.Data;
|
||||
|
||||
namespace Zentral.API.Modules.Training.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/training/notifications")]
|
||||
public class TrainingNotificationsController : ControllerBase
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly TrainingNotificationService _notificationService;
|
||||
|
||||
public TrainingNotificationsController(ZentralDbContext context, TrainingNotificationService notificationService)
|
||||
{
|
||||
_context = context;
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<TrainingNotification>>> GetNotifications(
|
||||
[FromQuery] NotificationStatus? status,
|
||||
[FromQuery] int? clienteId)
|
||||
{
|
||||
var query = _context.TrainingNotifications
|
||||
.Include(n => n.Cliente)
|
||||
.AsQueryable();
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(n => n.Status == status.Value);
|
||||
|
||||
if (clienteId.HasValue)
|
||||
query = query.Where(n => n.ClienteId == clienteId);
|
||||
|
||||
return await query.OrderByDescending(n => n.ScheduledDate).ToListAsync();
|
||||
}
|
||||
|
||||
[HttpPost("generate")]
|
||||
public async Task<IActionResult> GenerateNotifications([FromQuery] int days = 60)
|
||||
{
|
||||
var count = await _notificationService.GenerateNotificationsAsync(days);
|
||||
return Ok(new { count, message = $"Generate {count} notifiche in attesa." });
|
||||
}
|
||||
|
||||
[HttpPost("{id}/approve")]
|
||||
public async Task<IActionResult> ApproveNotification(int id)
|
||||
{
|
||||
var notification = await _context.TrainingNotifications.FindAsync(id);
|
||||
if (notification == null) return NotFound();
|
||||
|
||||
if (notification.Status != NotificationStatus.Pending)
|
||||
return BadRequest("Solo le notifiche in attesa possono essere approvate.");
|
||||
|
||||
notification.Status = NotificationStatus.Approved;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(notification);
|
||||
}
|
||||
|
||||
[HttpPost("approve-selected")]
|
||||
public async Task<IActionResult> ApproveSelected([FromBody] List<int> ids)
|
||||
{
|
||||
var notifications = await _context.TrainingNotifications
|
||||
.Where(n => ids.Contains(n.Id) && n.Status == NotificationStatus.Pending)
|
||||
.ToListAsync();
|
||||
|
||||
foreach(var n in notifications)
|
||||
{
|
||||
n.Status = NotificationStatus.Approved;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok(new { count = notifications.Count });
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
public async Task<IActionResult> SendApproved()
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _notificationService.SendApprovedNotificationsAsync();
|
||||
return Ok(new { count, message = $"Inviate {count} notifiche." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateNotification(int id, [FromBody] TrainingNotification update)
|
||||
{
|
||||
var notification = await _context.TrainingNotifications.FindAsync(id);
|
||||
if (notification == null) return NotFound();
|
||||
|
||||
if (notification.Status == NotificationStatus.Sent)
|
||||
return BadRequest("Non è possibile modificare notifiche già inviate.");
|
||||
|
||||
notification.Subject = update.Subject;
|
||||
notification.Body = update.Body;
|
||||
notification.RecipientEmail = update.RecipientEmail;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return Ok(notification);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteNotification(int id)
|
||||
{
|
||||
var notification = await _context.TrainingNotifications.FindAsync(id);
|
||||
if (notification == null) return NotFound();
|
||||
|
||||
_context.TrainingNotifications.Remove(notification);
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Domain.Entities.Training;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Zentral.Domain.Interfaces;
|
||||
|
||||
namespace Zentral.API.Modules.Training.Services;
|
||||
|
||||
public class TrainingNotificationService
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly Zentral.API.Services.AppService _appService;
|
||||
|
||||
public TrainingNotificationService(ZentralDbContext context, IEmailSender emailSender, Zentral.API.Services.AppService appService)
|
||||
{
|
||||
_context = context;
|
||||
_emailSender = emailSender;
|
||||
_appService = appService;
|
||||
}
|
||||
|
||||
public async Task<int> GenerateNotificationsAsync(int daysThreshold = 60)
|
||||
{
|
||||
var thresholdDate = DateTime.Today.AddDays(daysThreshold);
|
||||
|
||||
// 1. Find Expiring or Expired records
|
||||
var expiringRecords = await _context.TrainingRecords
|
||||
.Include(t => t.ClienteContatto)
|
||||
.ThenInclude(c => c.Cliente)
|
||||
.Include(t => t.Articolo)
|
||||
.Where(t => t.DataScadenza != null && t.DataScadenza <= thresholdDate) // Expired or Expiring soon
|
||||
.Where(t => t.ClienteContatto.Cliente != null && t.ClienteContatto.Cliente.Attivo)
|
||||
.ToListAsync();
|
||||
|
||||
// 2. Group by Client
|
||||
var groupedByClient = expiringRecords.GroupBy(t => t.ClienteContatto.ClienteId);
|
||||
|
||||
int generatedCount = 0;
|
||||
|
||||
foreach (var group in groupedByClient)
|
||||
{
|
||||
var clienteId = group.Key;
|
||||
var records = group.ToList();
|
||||
var cliente = records.First().ClienteContatto.Cliente;
|
||||
|
||||
// 3. Check for existing PENDING notifications for this client
|
||||
var existingNotification = await _context.TrainingNotifications
|
||||
.FirstOrDefaultAsync(n => n.ClienteId == clienteId && n.Status == NotificationStatus.Pending);
|
||||
|
||||
if (existingNotification != null)
|
||||
{
|
||||
// Logic to update existing notification?
|
||||
// For now, let's assume we skip if pending exists to avoid confusion,
|
||||
// OR we could regenerate the body. Let's regenerate.
|
||||
UpdateNotificationContent(existingNotification, cliente, records);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new
|
||||
var notification = new TrainingNotification
|
||||
{
|
||||
ClienteId = clienteId,
|
||||
Status = NotificationStatus.Pending,
|
||||
ScheduledDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
UpdateNotificationContent(notification, cliente, records);
|
||||
_context.TrainingNotifications.Add(notification);
|
||||
generatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return generatedCount;
|
||||
}
|
||||
|
||||
private void UpdateNotificationContent(TrainingNotification notification, Cliente cliente, List<TrainingRecord> records)
|
||||
{
|
||||
// Determine Recipient
|
||||
// Priority: Contact with Role "Referente Formazione" -> Client Email -> First Contact Email
|
||||
var referente = cliente.Contatti?.FirstOrDefault(c => c.Ruolo?.Contains("Referente", StringComparison.OrdinalIgnoreCase) == true);
|
||||
notification.RecipientEmail = referente?.Email ?? cliente.Email ?? cliente.Contatti?.FirstOrDefault()?.Email ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(notification.RecipientEmail))
|
||||
{
|
||||
notification.ErrorMessage = "Nessuna email valida trovata per il cliente.";
|
||||
notification.Status = NotificationStatus.Error; // Cannot send
|
||||
}
|
||||
|
||||
// Subject
|
||||
notification.Subject = $"Riepilogo Scadenze Formazione - {cliente.RagioneSociale}";
|
||||
|
||||
// Body Construction (HTML Table)
|
||||
var body = $@"
|
||||
<h3>Riepilogo Scadenze Formazione - {cliente.RagioneSociale}</h3>
|
||||
<p>Gentile Referente,</p>
|
||||
<p>Di seguito riportiamo l'elenco dei corsi di formazione in scadenza o scaduti per i vostri collaboratori:</p>
|
||||
<table border='1' cellpadding='5' cellspacing='0' style='border-collapse: collapse; width: 100%;'>
|
||||
<tr style='background-color: #f2f2f2;'>
|
||||
<th>Lavoratore</th>
|
||||
<th>Corso</th>
|
||||
<th>Data Esecuzione</th>
|
||||
<th>Scadenza</th>
|
||||
<th>Stato</th>
|
||||
</tr>";
|
||||
|
||||
foreach (var rec in records.OrderBy(r => r.DataScadenza))
|
||||
{
|
||||
var style = rec.Stato == TrainingStatus.Expired ? "color: red; font-weight: bold;" : "color: orange;";
|
||||
var statoText = rec.Stato == TrainingStatus.Expired ? "SCADUTO" : "In Scadenza";
|
||||
|
||||
body += $@"
|
||||
<tr>
|
||||
<td>{rec.ClienteContatto.Nome} {rec.ClienteContatto.Cognome}</td>
|
||||
<td>{rec.Articolo.Descrizione}</td>
|
||||
<td>{rec.DataEsecuzione:dd/MM/yyyy}</td>
|
||||
<td style='{style}'>{rec.DataScadenza:dd/MM/yyyy}</td>
|
||||
<td style='{style}'>{statoText}</td>
|
||||
</tr>";
|
||||
}
|
||||
|
||||
body += @"</table>
|
||||
<p>Vi preghiamo di pianificare i rinnovi il prima possibile.</p>
|
||||
<p>Cordiali saluti,<br>Ufficio Formazione</p>";
|
||||
|
||||
notification.Body = body;
|
||||
|
||||
// Track IDs
|
||||
notification.IncludedRecordIds = JsonSerializer.Serialize(records.Select(r => r.Id).ToList());
|
||||
}
|
||||
|
||||
public async Task<int> SendApprovedNotificationsAsync()
|
||||
{
|
||||
if (!await _appService.IsAppEnabledAsync("communications"))
|
||||
throw new InvalidOperationException("Modulo Comunicazioni non attivo.");
|
||||
|
||||
var toSend = await _context.TrainingNotifications
|
||||
.Where(n => n.Status == NotificationStatus.Approved)
|
||||
.ToListAsync();
|
||||
|
||||
int sentCount = 0;
|
||||
|
||||
foreach (var notif in toSend)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(notif.RecipientEmail))
|
||||
{
|
||||
notif.Status = NotificationStatus.Error;
|
||||
notif.ErrorMessage = "Indirizzo email mancante.";
|
||||
continue;
|
||||
}
|
||||
|
||||
await _emailSender.SendEmailAsync(notif.RecipientEmail, notif.Subject, notif.Body);
|
||||
|
||||
notif.Status = NotificationStatus.Sent;
|
||||
notif.SentDate = DateTime.UtcNow;
|
||||
sentCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
notif.Status = NotificationStatus.Error;
|
||||
notif.ErrorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return sentCount;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ using Zentral.API.Hubs;
|
||||
using Zentral.API.Services;
|
||||
// Trigger rebuild
|
||||
using Zentral.API.Services.Reports;
|
||||
using Zentral.API.Modules.Warehouse.Services;
|
||||
using Zentral.API.Modules.Purchases.Services;
|
||||
using Zentral.API.Modules.Sales.Services;
|
||||
using Zentral.API.Modules.Production.Services;
|
||||
using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.API.Apps.Purchases.Services;
|
||||
using Zentral.API.Apps.Sales.Services;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Zentral.Infrastructure.Services;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -19,14 +22,19 @@ builder.Services.AddDbContext<ZentralDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
|
||||
// Services
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddScoped<EventoCostiService>();
|
||||
builder.Services.AddScoped<DemoDataService>();
|
||||
builder.Services.AddScoped<ReportGeneratorService>();
|
||||
builder.Services.AddScoped<ModuleService>();
|
||||
builder.Services.AddScoped<SchemaDiscoveryService>();
|
||||
builder.Services.AddScoped<AppService>();
|
||||
builder.Services.AddScoped<AutoCodeService>();
|
||||
builder.Services.AddScoped<CustomFieldService>();
|
||||
builder.Services.AddSingleton<DataNotificationService>();
|
||||
|
||||
// Communications Module Services
|
||||
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
|
||||
|
||||
// Warehouse Module Services
|
||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||
|
||||
@@ -41,6 +49,9 @@ builder.Services.AddScoped<SalesService>();
|
||||
builder.Services.AddScoped<IProductionService, ProductionService>();
|
||||
builder.Services.AddScoped<IMrpService, MrpService>();
|
||||
|
||||
// Training Module Services
|
||||
builder.Services.AddScoped<Zentral.API.Modules.Training.Services.TrainingNotificationService>();
|
||||
|
||||
// Memory cache for module state
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
@@ -110,9 +121,14 @@ using (var scope = app.Services.CreateScope())
|
||||
// Seed data (only in development or if database is empty)
|
||||
DbSeeder.Seed(db);
|
||||
|
||||
// Seed default modules
|
||||
var moduleService = scope.ServiceProvider.GetRequiredService<ModuleService>();
|
||||
await moduleService.SeedDefaultModulesAsync();
|
||||
// Seed default apps
|
||||
var appService = scope.ServiceProvider.GetRequiredService<AppService>();
|
||||
await appService.SeedDefaultAppsAsync();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await appService.SeedDevSubscriptionsAsync();
|
||||
}
|
||||
|
||||
// Seed warehouse default data
|
||||
var warehouseService = scope.ServiceProvider.GetRequiredService<IWarehouseService>();
|
||||
|
||||
615
src/backend/Zentral.API/Services/AppService.cs
Normal file
615
src/backend/Zentral.API/Services/AppService.cs
Normal file
@@ -0,0 +1,615 @@
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Zentral.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service per la gestione delle applicazioni e delle relative subscription
|
||||
/// </summary>
|
||||
public class AppService
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<AppService> _logger;
|
||||
|
||||
private const string APPS_CACHE_KEY = "apps_all";
|
||||
private const string ACTIVE_APPS_CACHE_KEY = "apps_active";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public AppService(
|
||||
ZentralDbContext context,
|
||||
IMemoryCache cache,
|
||||
ILogger<AppService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutte le applicazioni con lo stato della subscription
|
||||
/// </summary>
|
||||
public async Task<List<App>> GetAllAppsAsync()
|
||||
{
|
||||
return await _cache.GetOrCreateAsync(APPS_CACHE_KEY, async entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
|
||||
return await _context.Apps
|
||||
.Include(m => m.Subscription)
|
||||
.Where(m => m.IsAvailable)
|
||||
.OrderBy(m => m.SortOrder)
|
||||
.ThenBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}) ?? new List<App>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene solo le applicazioni attive (per la costruzione del menu)
|
||||
/// </summary>
|
||||
public async Task<List<App>> GetActiveAppsAsync()
|
||||
{
|
||||
return await _cache.GetOrCreateAsync(ACTIVE_APPS_CACHE_KEY, async entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
|
||||
var apps = await _context.Apps
|
||||
.Include(m => m.Subscription)
|
||||
.Where(m => m.IsAvailable)
|
||||
.OrderBy(m => m.SortOrder)
|
||||
.ThenBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return apps.Where(m => m.IsCore || ((m.Subscription?.IsEnabled ?? false) && (m.Subscription?.IsValid() ?? false))).ToList();
|
||||
}) ?? new List<App>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un'applicazione specifica per codice
|
||||
/// </summary>
|
||||
public async Task<App?> GetAppByCodeAsync(string code)
|
||||
{
|
||||
return await _context.Apps
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se un'applicazione è attualmente abilitata
|
||||
/// </summary>
|
||||
public async Task<bool> IsAppEnabledAsync(string code)
|
||||
{
|
||||
var app = await GetAppByCodeAsync(code);
|
||||
if (app == null)
|
||||
return false;
|
||||
|
||||
// Le applicazioni core sono sempre abilitate
|
||||
if (app.IsCore)
|
||||
return true;
|
||||
|
||||
return (app.Subscription?.IsEnabled ?? false) && (app.Subscription?.IsValid() ?? false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se un'applicazione ha una subscription valida (non scaduta)
|
||||
/// </summary>
|
||||
public async Task<bool> HasValidSubscriptionAsync(string code)
|
||||
{
|
||||
var app = await GetAppByCodeAsync(code);
|
||||
return app?.Subscription?.IsValid() ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attiva un'applicazione creando o aggiornando la subscription
|
||||
/// </summary>
|
||||
public async Task<AppSubscription> EnableAppAsync(
|
||||
string code,
|
||||
SubscriptionType subscriptionType,
|
||||
DateTime? startDate = null,
|
||||
DateTime? endDate = null,
|
||||
bool autoRenew = false,
|
||||
decimal? paidPrice = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var app = await _context.Apps
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
|
||||
if (app == null)
|
||||
throw new ArgumentException($"Applicazione con codice '{code}' non trovata");
|
||||
|
||||
if (app.IsCore)
|
||||
throw new InvalidOperationException("Le applicazioni core non possono essere attivate/disattivate manualmente");
|
||||
|
||||
// Verifica dipendenze
|
||||
var missingDeps = await CheckDependenciesAsync(app);
|
||||
if (missingDeps.Any())
|
||||
throw new InvalidOperationException(
|
||||
$"L'applicazione richiede le seguenti applicazioni attive: {string.Join(", ", missingDeps)}");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var effectiveStartDate = startDate ?? now;
|
||||
|
||||
// Calcola data fine se non specificata
|
||||
DateTime? effectiveEndDate = endDate;
|
||||
if (!effectiveEndDate.HasValue && subscriptionType != SubscriptionType.None)
|
||||
{
|
||||
effectiveEndDate = subscriptionType switch
|
||||
{
|
||||
SubscriptionType.Monthly => effectiveStartDate.AddMonths(1),
|
||||
SubscriptionType.Annual => effectiveStartDate.AddYears(1),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
if (app.Subscription == null)
|
||||
{
|
||||
// Crea nuova subscription
|
||||
app.Subscription = new AppSubscription
|
||||
{
|
||||
AppId = app.Id,
|
||||
IsEnabled = true,
|
||||
SubscriptionType = subscriptionType,
|
||||
StartDate = effectiveStartDate,
|
||||
EndDate = effectiveEndDate,
|
||||
AutoRenew = autoRenew,
|
||||
PaidPrice = paidPrice ?? app.BasePrice,
|
||||
Notes = notes,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
_context.AppSubscriptions.Add(app.Subscription);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Aggiorna subscription esistente
|
||||
app.Subscription.IsEnabled = true;
|
||||
app.Subscription.SubscriptionType = subscriptionType;
|
||||
app.Subscription.StartDate = effectiveStartDate;
|
||||
app.Subscription.EndDate = effectiveEndDate;
|
||||
app.Subscription.AutoRenew = autoRenew;
|
||||
app.Subscription.PaidPrice = paidPrice ?? app.Subscription.PaidPrice ?? app.BasePrice;
|
||||
if (notes != null) app.Subscription.Notes = notes;
|
||||
app.Subscription.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Applicazione {AppCode} attivata con subscription {Type} fino a {EndDate}",
|
||||
code, subscriptionType, effectiveEndDate);
|
||||
|
||||
return app.Subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disattiva un'applicazione (mantiene i dati ma rimuove l'accesso)
|
||||
/// </summary>
|
||||
public async Task DisableAppAsync(string code)
|
||||
{
|
||||
var app = await _context.Apps
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
|
||||
if (app == null)
|
||||
throw new ArgumentException($"Applicazione con codice '{code}' non trovata");
|
||||
|
||||
if (app.IsCore)
|
||||
throw new InvalidOperationException("Le applicazioni core non possono essere disattivate");
|
||||
|
||||
// Verifica se altre applicazioni dipendono da questa
|
||||
var dependentApps = await GetDependentAppsAsync(code);
|
||||
var activeDependents = dependentApps.Where(m => (m.Subscription?.IsEnabled ?? false) && (m.Subscription?.IsValid() ?? false)).ToList();
|
||||
if (activeDependents.Any())
|
||||
throw new InvalidOperationException(
|
||||
$"Le seguenti applicazioni attive dipendono da questa applicazione: {string.Join(", ", activeDependents.Select(m => m.Name))}");
|
||||
|
||||
if (app.Subscription != null)
|
||||
{
|
||||
app.Subscription.IsEnabled = false;
|
||||
app.Subscription.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
InvalidateCache();
|
||||
_logger.LogInformation("Applicazione {AppCode} disattivata", code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna i dettagli della subscription
|
||||
/// </summary>
|
||||
public async Task<AppSubscription> UpdateSubscriptionAsync(
|
||||
string code,
|
||||
SubscriptionType? subscriptionType = null,
|
||||
DateTime? endDate = null,
|
||||
bool? autoRenew = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var app = await _context.Apps
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
|
||||
if (app == null)
|
||||
throw new ArgumentException($"Applicazione con codice '{code}' non trovata");
|
||||
|
||||
if (app.Subscription == null)
|
||||
throw new InvalidOperationException($"L'applicazione '{code}' non ha una subscription attiva");
|
||||
|
||||
if (subscriptionType.HasValue)
|
||||
app.Subscription.SubscriptionType = subscriptionType.Value;
|
||||
if (endDate.HasValue)
|
||||
app.Subscription.EndDate = endDate.Value;
|
||||
if (autoRenew.HasValue)
|
||||
app.Subscription.AutoRenew = autoRenew.Value;
|
||||
if (notes != null)
|
||||
app.Subscription.Notes = notes;
|
||||
|
||||
app.Subscription.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
|
||||
return app.Subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rinnova una subscription esistente
|
||||
/// </summary>
|
||||
public async Task<AppSubscription> RenewSubscriptionAsync(string code, decimal? paidPrice = null)
|
||||
{
|
||||
var app = await _context.Apps
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
|
||||
if (app == null)
|
||||
throw new ArgumentException($"Applicazione con codice '{code}' non trovata");
|
||||
|
||||
if (app.Subscription == null)
|
||||
throw new InvalidOperationException($"L'applicazione '{code}' non ha una subscription da rinnovare");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var currentEnd = app.Subscription.EndDate ?? now;
|
||||
var newStart = currentEnd > now ? currentEnd : now;
|
||||
|
||||
var newEnd = app.Subscription.SubscriptionType switch
|
||||
{
|
||||
SubscriptionType.Monthly => newStart.AddMonths(1),
|
||||
SubscriptionType.Annual => newStart.AddYears(1),
|
||||
_ => newStart.AddYears(1) // Default to annual
|
||||
};
|
||||
|
||||
app.Subscription.StartDate = newStart;
|
||||
app.Subscription.EndDate = newEnd;
|
||||
app.Subscription.LastRenewalDate = now;
|
||||
app.Subscription.IsEnabled = true;
|
||||
app.Subscription.PaidPrice = paidPrice ?? app.Subscription.PaidPrice;
|
||||
app.Subscription.UpdatedAt = now;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Applicazione {AppCode} rinnovata fino a {EndDate}",
|
||||
code, newEnd);
|
||||
|
||||
return app.Subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutte le subscription
|
||||
/// </summary>
|
||||
public async Task<List<AppSubscription>> GetAllSubscriptionsAsync()
|
||||
{
|
||||
return await _context.AppSubscriptions
|
||||
.Include(s => s.App)
|
||||
.OrderBy(s => s.App.SortOrder)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica e disattiva le applicazioni con subscription scaduta (per job schedulato)
|
||||
/// </summary>
|
||||
public async Task<int> CheckExpiredSubscriptionsAsync()
|
||||
{
|
||||
var expiredSubscriptions = await _context.AppSubscriptions
|
||||
.Include(s => s.App)
|
||||
.Where(s => s.IsEnabled &&
|
||||
s.EndDate.HasValue &&
|
||||
s.EndDate.Value < DateTime.UtcNow &&
|
||||
!s.AutoRenew)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var subscription in expiredSubscriptions)
|
||||
{
|
||||
subscription.IsEnabled = false;
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
_logger.LogWarning(
|
||||
"Applicazione {AppCode} disattivata per scadenza subscription",
|
||||
subscription.App.Code);
|
||||
}
|
||||
|
||||
if (expiredSubscriptions.Any())
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
}
|
||||
|
||||
return expiredSubscriptions.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le applicazioni in scadenza entro N giorni
|
||||
/// </summary>
|
||||
public async Task<List<App>> GetExpiringAppsAsync(int daysThreshold = 30)
|
||||
{
|
||||
var thresholdDate = DateTime.UtcNow.AddDays(daysThreshold);
|
||||
|
||||
return await _context.Apps
|
||||
.Include(m => m.Subscription)
|
||||
.Where(m => m.Subscription != null &&
|
||||
m.Subscription.IsEnabled &&
|
||||
m.Subscription.EndDate.HasValue &&
|
||||
m.Subscription.EndDate.Value <= thresholdDate &&
|
||||
m.Subscription.EndDate.Value > DateTime.UtcNow)
|
||||
.OrderBy(m => m.Subscription!.EndDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica le dipendenze mancanti per un'applicazione
|
||||
/// </summary>
|
||||
private async Task<List<string>> CheckDependenciesAsync(App app)
|
||||
{
|
||||
var dependencies = app.GetDependencies().ToList();
|
||||
if (!dependencies.Any())
|
||||
return new List<string>();
|
||||
|
||||
var missingDeps = new List<string>();
|
||||
|
||||
foreach (var depCode in dependencies)
|
||||
{
|
||||
if (!await IsAppEnabledAsync(depCode))
|
||||
{
|
||||
var depApp = await GetAppByCodeAsync(depCode);
|
||||
missingDeps.Add(depApp?.Name ?? depCode);
|
||||
}
|
||||
}
|
||||
|
||||
return missingDeps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene le applicazioni che dipendono da una determinata applicazione
|
||||
/// </summary>
|
||||
private async Task<List<App>> GetDependentAppsAsync(string code)
|
||||
{
|
||||
var allApps = await GetAllAppsAsync();
|
||||
return allApps
|
||||
.Where(m => m.GetDependencies().Contains(code))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalida la cache delle applicazioni
|
||||
/// </summary>
|
||||
public void InvalidateCache()
|
||||
{
|
||||
_cache.Remove(APPS_CACHE_KEY);
|
||||
_cache.Remove(ACTIVE_APPS_CACHE_KEY);
|
||||
_logger.LogDebug("Cache applicazioni invalidata");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inizializza le applicazioni di default se non esistono
|
||||
/// </summary>
|
||||
public async Task SeedDefaultAppsAsync()
|
||||
{
|
||||
var defaultApps = new List<App>
|
||||
{
|
||||
new App
|
||||
{
|
||||
Code = "warehouse",
|
||||
Name = "Magazzino",
|
||||
Description = "Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte",
|
||||
Icon = "Warehouse",
|
||||
BasePrice = 1200m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 10,
|
||||
IsCore = false,
|
||||
RoutePath = "/warehouse",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "purchases",
|
||||
Name = "Acquisti",
|
||||
Description = "Gestione ordini fornitori, DDT in entrata, fatture passive e analisi acquisti",
|
||||
Icon = "ShoppingCart",
|
||||
BasePrice = 1500m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 20,
|
||||
IsCore = false,
|
||||
Dependencies = "warehouse",
|
||||
RoutePath = "/purchases",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "sales",
|
||||
Name = "Vendite",
|
||||
Description = "Gestione ordini clienti, DDT in uscita, fatture attive e analisi vendite",
|
||||
Icon = "PointOfSale",
|
||||
BasePrice = 1500m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 30,
|
||||
IsCore = false,
|
||||
Dependencies = "warehouse",
|
||||
RoutePath = "/sales",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "production",
|
||||
Name = "Produzione",
|
||||
Description = "Cicli produttivi, distinte base, pianificazione MRP e controllo avanzamento",
|
||||
Icon = "Precision Manufacturing",
|
||||
BasePrice = 2500m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 40,
|
||||
IsCore = false,
|
||||
Dependencies = "warehouse",
|
||||
RoutePath = "/production",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "quality",
|
||||
Name = "Qualità",
|
||||
Description = "Controlli qualità, gestione non conformità, certificazioni e audit",
|
||||
Icon = "VerifiedUser",
|
||||
BasePrice = 1800m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 50,
|
||||
IsCore = false,
|
||||
RoutePath = "/quality",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "events",
|
||||
Name = "Gestione Eventi",
|
||||
Description = "Gestione eventi, pianificazione e controllo avanzamento",
|
||||
Icon = "Event",
|
||||
BasePrice = 2000m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 60,
|
||||
IsCore = false,
|
||||
RoutePath = "/events",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "hr",
|
||||
Name = "Gestione Personale",
|
||||
Description = "Gestione personale, contratti, pagamenti, assenze, rimborsi e analisi personale",
|
||||
Icon = "People",
|
||||
BasePrice = 1600m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 70,
|
||||
IsCore = false,
|
||||
RoutePath = "/hr",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "report-designer",
|
||||
Name = "Report Designer",
|
||||
Description = "Creazione e personalizzazione di report e stampe",
|
||||
Icon = "Print",
|
||||
BasePrice = 1000m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 80,
|
||||
IsCore = false,
|
||||
RoutePath = "/report-designer",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "communications",
|
||||
Name = "Comunicazioni",
|
||||
Description = "Gestione invio mail, chat interna e condivisione risorse",
|
||||
Icon = "Email",
|
||||
BasePrice = 1000m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 90,
|
||||
IsCore = false,
|
||||
RoutePath = "/communications",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "training",
|
||||
Name = "Formazione",
|
||||
Description = "Gestione formazione obbligatoria, corsi, scadenze e attestati",
|
||||
Icon = "School",
|
||||
BasePrice = 1400m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 100,
|
||||
IsCore = false,
|
||||
RoutePath = "/training",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var existingCodes = await _context.Apps.Select(m => m.Code).ToListAsync();
|
||||
var newApps = defaultApps.Where(m => !existingCodes.Contains(m.Code)).ToList();
|
||||
|
||||
if (newApps.Any())
|
||||
{
|
||||
_context.Apps.AddRange(newApps);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Added {Count} new default apps: {Apps}",
|
||||
newApps.Count, string.Join(", ", newApps.Select(m => m.Code)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attiva tutte le applicazioni per ambiente di sviluppo
|
||||
/// </summary>
|
||||
public async Task SeedDevSubscriptionsAsync()
|
||||
{
|
||||
var apps = await _context.Apps
|
||||
.Include(a => a.Subscription)
|
||||
.ToListAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
bool changes = false;
|
||||
|
||||
foreach (var app in apps)
|
||||
{
|
||||
if (app.Subscription == null)
|
||||
{
|
||||
app.Subscription = new AppSubscription
|
||||
{
|
||||
AppId = app.Id,
|
||||
IsEnabled = true,
|
||||
SubscriptionType = SubscriptionType.Annual,
|
||||
StartDate = now,
|
||||
EndDate = now.AddYears(1),
|
||||
AutoRenew = true,
|
||||
PaidPrice = 0,
|
||||
Notes = "Auto-generated for Development",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
_context.AppSubscriptions.Add(app.Subscription);
|
||||
changes = true;
|
||||
}
|
||||
else if (!app.Subscription.IsEnabled)
|
||||
{
|
||||
app.Subscription.IsEnabled = true;
|
||||
app.Subscription.EndDate = now.AddYears(1);
|
||||
app.Subscription.UpdatedAt = now;
|
||||
changes = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changes)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
_logger.LogInformation("Dev subscriptions seeded for all apps");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Zentral.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service per la gestione dei moduli applicativi e delle relative subscription
|
||||
/// </summary>
|
||||
public class ModuleService
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<ModuleService> _logger;
|
||||
|
||||
private const string MODULES_CACHE_KEY = "modules_all";
|
||||
private const string ACTIVE_MODULES_CACHE_KEY = "modules_active";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public ModuleService(
|
||||
ZentralDbContext context,
|
||||
IMemoryCache cache,
|
||||
ILogger<ModuleService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutti i moduli con lo stato della subscription
|
||||
/// </summary>
|
||||
public async Task<List<AppModule>> GetAllModulesAsync()
|
||||
{
|
||||
return await _cache.GetOrCreateAsync(MODULES_CACHE_KEY, async entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
|
||||
return await _context.AppModules
|
||||
.Include(m => m.Subscription)
|
||||
.Where(m => m.IsAvailable)
|
||||
.OrderBy(m => m.SortOrder)
|
||||
.ThenBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}) ?? new List<AppModule>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene solo i moduli attivi (per la costruzione del menu)
|
||||
/// </summary>
|
||||
public async Task<List<AppModule>> GetActiveModulesAsync()
|
||||
{
|
||||
return await _cache.GetOrCreateAsync(ACTIVE_MODULES_CACHE_KEY, async entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
|
||||
var modules = await _context.AppModules
|
||||
.Include(m => m.Subscription)
|
||||
.Where(m => m.IsAvailable)
|
||||
.OrderBy(m => m.SortOrder)
|
||||
.ThenBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return modules.Where(m => m.IsCore || (m.Subscription?.IsValid() ?? false)).ToList();
|
||||
}) ?? new List<AppModule>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene un modulo specifico per codice
|
||||
/// </summary>
|
||||
public async Task<AppModule?> GetModuleByCodeAsync(string code)
|
||||
{
|
||||
return await _context.AppModules
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se un modulo è attualmente abilitato
|
||||
/// </summary>
|
||||
public async Task<bool> IsModuleEnabledAsync(string code)
|
||||
{
|
||||
var module = await GetModuleByCodeAsync(code);
|
||||
if (module == null)
|
||||
return false;
|
||||
|
||||
// I moduli core sono sempre abilitati
|
||||
if (module.IsCore)
|
||||
return true;
|
||||
|
||||
return module.Subscription?.IsValid() ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se un modulo ha una subscription valida (non scaduta)
|
||||
/// </summary>
|
||||
public async Task<bool> HasValidSubscriptionAsync(string code)
|
||||
{
|
||||
var module = await GetModuleByCodeAsync(code);
|
||||
return module?.Subscription?.IsValid() ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attiva un modulo creando o aggiornando la subscription
|
||||
/// </summary>
|
||||
public async Task<ModuleSubscription> EnableModuleAsync(
|
||||
string code,
|
||||
SubscriptionType subscriptionType,
|
||||
DateTime? startDate = null,
|
||||
DateTime? endDate = null,
|
||||
bool autoRenew = false,
|
||||
decimal? paidPrice = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var module = await _context.AppModules
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
|
||||
if (module == null)
|
||||
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
|
||||
|
||||
if (module.IsCore)
|
||||
throw new InvalidOperationException("I moduli core non possono essere attivati/disattivati manualmente");
|
||||
|
||||
// Verifica dipendenze
|
||||
var missingDeps = await CheckDependenciesAsync(module);
|
||||
if (missingDeps.Any())
|
||||
throw new InvalidOperationException(
|
||||
$"Il modulo richiede i seguenti moduli attivi: {string.Join(", ", missingDeps)}");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var effectiveStartDate = startDate ?? now;
|
||||
|
||||
// Calcola data fine se non specificata
|
||||
DateTime? effectiveEndDate = endDate;
|
||||
if (!effectiveEndDate.HasValue && subscriptionType != SubscriptionType.None)
|
||||
{
|
||||
effectiveEndDate = subscriptionType switch
|
||||
{
|
||||
SubscriptionType.Monthly => effectiveStartDate.AddMonths(1),
|
||||
SubscriptionType.Annual => effectiveStartDate.AddYears(1),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
if (module.Subscription == null)
|
||||
{
|
||||
// Crea nuova subscription
|
||||
module.Subscription = new ModuleSubscription
|
||||
{
|
||||
ModuleId = module.Id,
|
||||
IsEnabled = true,
|
||||
SubscriptionType = subscriptionType,
|
||||
StartDate = effectiveStartDate,
|
||||
EndDate = effectiveEndDate,
|
||||
AutoRenew = autoRenew,
|
||||
PaidPrice = paidPrice ?? module.BasePrice,
|
||||
Notes = notes,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
_context.ModuleSubscriptions.Add(module.Subscription);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Aggiorna subscription esistente
|
||||
module.Subscription.IsEnabled = true;
|
||||
module.Subscription.SubscriptionType = subscriptionType;
|
||||
module.Subscription.StartDate = effectiveStartDate;
|
||||
module.Subscription.EndDate = effectiveEndDate;
|
||||
module.Subscription.AutoRenew = autoRenew;
|
||||
module.Subscription.PaidPrice = paidPrice ?? module.Subscription.PaidPrice ?? module.BasePrice;
|
||||
if (notes != null) module.Subscription.Notes = notes;
|
||||
module.Subscription.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Modulo {ModuleCode} attivato con subscription {Type} fino a {EndDate}",
|
||||
code, subscriptionType, effectiveEndDate);
|
||||
|
||||
return module.Subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disattiva un modulo (mantiene i dati ma rimuove l'accesso)
|
||||
/// </summary>
|
||||
public async Task DisableModuleAsync(string code)
|
||||
{
|
||||
var module = await _context.AppModules
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
|
||||
if (module == null)
|
||||
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
|
||||
|
||||
if (module.IsCore)
|
||||
throw new InvalidOperationException("I moduli core non possono essere disattivati");
|
||||
|
||||
// Verifica se altri moduli dipendono da questo
|
||||
var dependentModules = await GetDependentModulesAsync(code);
|
||||
var activeDependents = dependentModules.Where(m => m.Subscription?.IsValid() ?? false).ToList();
|
||||
if (activeDependents.Any())
|
||||
throw new InvalidOperationException(
|
||||
$"I seguenti moduli attivi dipendono da questo modulo: {string.Join(", ", activeDependents.Select(m => m.Name))}");
|
||||
|
||||
if (module.Subscription != null)
|
||||
{
|
||||
module.Subscription.IsEnabled = false;
|
||||
module.Subscription.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
InvalidateCache();
|
||||
_logger.LogInformation("Modulo {ModuleCode} disattivato", code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggiorna i dettagli della subscription
|
||||
/// </summary>
|
||||
public async Task<ModuleSubscription> UpdateSubscriptionAsync(
|
||||
string code,
|
||||
SubscriptionType? subscriptionType = null,
|
||||
DateTime? endDate = null,
|
||||
bool? autoRenew = null,
|
||||
string? notes = null)
|
||||
{
|
||||
var module = await _context.AppModules
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
|
||||
if (module == null)
|
||||
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
|
||||
|
||||
if (module.Subscription == null)
|
||||
throw new InvalidOperationException($"Il modulo '{code}' non ha una subscription attiva");
|
||||
|
||||
if (subscriptionType.HasValue)
|
||||
module.Subscription.SubscriptionType = subscriptionType.Value;
|
||||
if (endDate.HasValue)
|
||||
module.Subscription.EndDate = endDate.Value;
|
||||
if (autoRenew.HasValue)
|
||||
module.Subscription.AutoRenew = autoRenew.Value;
|
||||
if (notes != null)
|
||||
module.Subscription.Notes = notes;
|
||||
|
||||
module.Subscription.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
|
||||
return module.Subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rinnova una subscription esistente
|
||||
/// </summary>
|
||||
public async Task<ModuleSubscription> RenewSubscriptionAsync(string code, decimal? paidPrice = null)
|
||||
{
|
||||
var module = await _context.AppModules
|
||||
.Include(m => m.Subscription)
|
||||
.FirstOrDefaultAsync(m => m.Code == code);
|
||||
|
||||
if (module == null)
|
||||
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
|
||||
|
||||
if (module.Subscription == null)
|
||||
throw new InvalidOperationException($"Il modulo '{code}' non ha una subscription da rinnovare");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var currentEnd = module.Subscription.EndDate ?? now;
|
||||
var newStart = currentEnd > now ? currentEnd : now;
|
||||
|
||||
var newEnd = module.Subscription.SubscriptionType switch
|
||||
{
|
||||
SubscriptionType.Monthly => newStart.AddMonths(1),
|
||||
SubscriptionType.Annual => newStart.AddYears(1),
|
||||
_ => newStart.AddYears(1) // Default to annual
|
||||
};
|
||||
|
||||
module.Subscription.StartDate = newStart;
|
||||
module.Subscription.EndDate = newEnd;
|
||||
module.Subscription.LastRenewalDate = now;
|
||||
module.Subscription.IsEnabled = true;
|
||||
module.Subscription.PaidPrice = paidPrice ?? module.Subscription.PaidPrice;
|
||||
module.Subscription.UpdatedAt = now;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Modulo {ModuleCode} rinnovato fino a {EndDate}",
|
||||
code, newEnd);
|
||||
|
||||
return module.Subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene tutte le subscription
|
||||
/// </summary>
|
||||
public async Task<List<ModuleSubscription>> GetAllSubscriptionsAsync()
|
||||
{
|
||||
return await _context.ModuleSubscriptions
|
||||
.Include(s => s.Module)
|
||||
.OrderBy(s => s.Module.SortOrder)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica e disattiva i moduli con subscription scaduta (per job schedulato)
|
||||
/// </summary>
|
||||
public async Task<int> CheckExpiredSubscriptionsAsync()
|
||||
{
|
||||
var expiredSubscriptions = await _context.ModuleSubscriptions
|
||||
.Include(s => s.Module)
|
||||
.Where(s => s.IsEnabled &&
|
||||
s.EndDate.HasValue &&
|
||||
s.EndDate.Value < DateTime.UtcNow &&
|
||||
!s.AutoRenew)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var subscription in expiredSubscriptions)
|
||||
{
|
||||
subscription.IsEnabled = false;
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
_logger.LogWarning(
|
||||
"Modulo {ModuleCode} disattivato per scadenza subscription",
|
||||
subscription.Module.Code);
|
||||
}
|
||||
|
||||
if (expiredSubscriptions.Any())
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
InvalidateCache();
|
||||
}
|
||||
|
||||
return expiredSubscriptions.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene i moduli in scadenza entro N giorni
|
||||
/// </summary>
|
||||
public async Task<List<AppModule>> GetExpiringModulesAsync(int daysThreshold = 30)
|
||||
{
|
||||
var thresholdDate = DateTime.UtcNow.AddDays(daysThreshold);
|
||||
|
||||
return await _context.AppModules
|
||||
.Include(m => m.Subscription)
|
||||
.Where(m => m.Subscription != null &&
|
||||
m.Subscription.IsEnabled &&
|
||||
m.Subscription.EndDate.HasValue &&
|
||||
m.Subscription.EndDate.Value <= thresholdDate &&
|
||||
m.Subscription.EndDate.Value > DateTime.UtcNow)
|
||||
.OrderBy(m => m.Subscription!.EndDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifica le dipendenze mancanti per un modulo
|
||||
/// </summary>
|
||||
private async Task<List<string>> CheckDependenciesAsync(AppModule module)
|
||||
{
|
||||
var dependencies = module.GetDependencies().ToList();
|
||||
if (!dependencies.Any())
|
||||
return new List<string>();
|
||||
|
||||
var missingDeps = new List<string>();
|
||||
|
||||
foreach (var depCode in dependencies)
|
||||
{
|
||||
if (!await IsModuleEnabledAsync(depCode))
|
||||
{
|
||||
var depModule = await GetModuleByCodeAsync(depCode);
|
||||
missingDeps.Add(depModule?.Name ?? depCode);
|
||||
}
|
||||
}
|
||||
|
||||
return missingDeps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ottiene i moduli che dipendono da un determinato modulo
|
||||
/// </summary>
|
||||
private async Task<List<AppModule>> GetDependentModulesAsync(string code)
|
||||
{
|
||||
var allModules = await GetAllModulesAsync();
|
||||
return allModules
|
||||
.Where(m => m.GetDependencies().Contains(code))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalida la cache dei moduli
|
||||
/// </summary>
|
||||
public void InvalidateCache()
|
||||
{
|
||||
_cache.Remove(MODULES_CACHE_KEY);
|
||||
_cache.Remove(ACTIVE_MODULES_CACHE_KEY);
|
||||
_logger.LogDebug("Cache moduli invalidata");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inizializza i moduli di default se non esistono
|
||||
/// </summary>
|
||||
public async Task SeedDefaultModulesAsync()
|
||||
{
|
||||
if (await _context.AppModules.AnyAsync())
|
||||
return;
|
||||
|
||||
var defaultModules = new List<AppModule>
|
||||
{
|
||||
new AppModule
|
||||
{
|
||||
Code = "warehouse",
|
||||
Name = "Magazzino",
|
||||
Description = "Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte",
|
||||
Icon = "Warehouse",
|
||||
BasePrice = 1200m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 10,
|
||||
IsCore = false,
|
||||
RoutePath = "/warehouse",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new AppModule
|
||||
{
|
||||
Code = "purchases",
|
||||
Name = "Acquisti",
|
||||
Description = "Gestione ordini fornitori, DDT in entrata, fatture passive e analisi acquisti",
|
||||
Icon = "ShoppingCart",
|
||||
BasePrice = 1500m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 20,
|
||||
IsCore = false,
|
||||
Dependencies = "warehouse",
|
||||
RoutePath = "/purchases",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new AppModule
|
||||
{
|
||||
Code = "sales",
|
||||
Name = "Vendite",
|
||||
Description = "Gestione ordini clienti, DDT in uscita, fatture attive e analisi vendite",
|
||||
Icon = "PointOfSale",
|
||||
BasePrice = 1500m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 30,
|
||||
IsCore = false,
|
||||
Dependencies = "warehouse",
|
||||
RoutePath = "/sales",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new AppModule
|
||||
{
|
||||
Code = "production",
|
||||
Name = "Produzione",
|
||||
Description = "Cicli produttivi, distinte base, pianificazione MRP e controllo avanzamento",
|
||||
Icon = "Precision Manufacturing",
|
||||
BasePrice = 2500m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 40,
|
||||
IsCore = false,
|
||||
Dependencies = "warehouse",
|
||||
RoutePath = "/production",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new AppModule
|
||||
{
|
||||
Code = "quality",
|
||||
Name = "Qualità",
|
||||
Description = "Controlli qualità, gestione non conformità, certificazioni e audit",
|
||||
Icon = "VerifiedUser",
|
||||
BasePrice = 1800m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 50,
|
||||
IsCore = false,
|
||||
RoutePath = "/quality",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
_context.AppModules.AddRange(defaultModules);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Seed {Count} moduli di default completato", defaultModules.Count);
|
||||
}
|
||||
}
|
||||
@@ -398,3 +398,66 @@ public class ReportImageDto
|
||||
public long FileSize { get; set; }
|
||||
public bool Attivo { get; set; } = true;
|
||||
}
|
||||
|
||||
// DTOs moved from ReportsController
|
||||
public class DebugBindingRequest
|
||||
{
|
||||
public List<DataSourceSelection> DataSources { get; set; } = new();
|
||||
public string? PropertyName { get; set; }
|
||||
}
|
||||
|
||||
public class PreviewReportRequest
|
||||
{
|
||||
public int TemplateId { get; set; }
|
||||
public List<DataSourceSelection> DataSources { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DataSourceSelection
|
||||
{
|
||||
public string DatasetId { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public string? Alias { get; set; }
|
||||
}
|
||||
|
||||
public class DatasetTypeDto
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = "Principale";
|
||||
public bool IsVirtual { get; set; } = false;
|
||||
}
|
||||
|
||||
public class EntityListItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SecondaryInfo { get; set; }
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public class DataSchemaDto
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public string DatasetId { get; set; } = string.Empty;
|
||||
public List<DataFieldDto> Fields { get; set; } = new();
|
||||
public List<DataCollectionDto> ChildCollections { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DataFieldDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "string";
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string? Group { get; set; }
|
||||
}
|
||||
|
||||
public class DataCollectionDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public List<DataFieldDto> Fields { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
using System.Collections;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Zentral.Domain.Entities;
|
||||
using Zentral.Infrastructure.Data;
|
||||
|
||||
namespace Zentral.API.Services.Reports;
|
||||
|
||||
public class SchemaDiscoveryService
|
||||
{
|
||||
private readonly ZentralDbContext _context;
|
||||
|
||||
public SchemaDiscoveryService(ZentralDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, (string Name, string Description, string Icon, string Category)> _datasetMetadata = new()
|
||||
{
|
||||
{ "Evento", ("Evento", "Dati evento completi con cliente, location, ospiti, costi e risorse", "event", "Principale") },
|
||||
{ "Cliente", ("Cliente", "Anagrafica clienti completa", "people", "Principale") },
|
||||
{ "Location", ("Location", "Sedi e location eventi", "place", "Principale") },
|
||||
{ "Articolo", ("Articolo", "Catalogo articoli e materiali", "inventory", "Principale") },
|
||||
{ "Risorsa", ("Risorsa", "Staff e personale", "person", "Principale") },
|
||||
{ "TipoEvento", ("Tipo Evento", "Tipologie di evento (matrimonio, compleanno, etc.)", "category", "Configurazione") },
|
||||
{ "TipoOspite", ("Tipo Ospite", "Tipologie di ospiti (adulti, bambini, etc.)", "groups", "Configurazione") },
|
||||
{ "CodiceCategoria", ("Categoria Articoli", "Categorie articoli con coefficienti di calcolo", "folder", "Configurazione") },
|
||||
{ "TipoRisorsa", ("Tipo Risorsa", "Tipologie di risorse (cameriere, cuoco, etc.)", "badge", "Configurazione") },
|
||||
{ "TipoMateriale", ("Tipo Materiale", "Tipologie di materiali", "category", "Configurazione") }
|
||||
};
|
||||
|
||||
public List<DatasetTypeDto> GetAvailableDatasets()
|
||||
{
|
||||
var datasets = new List<DatasetTypeDto>();
|
||||
var properties = typeof(ZentralDbContext).GetProperties()
|
||||
.Where(p => p.PropertyType.IsGenericType &&
|
||||
p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
var entityType = prop.PropertyType.GetGenericArguments()[0];
|
||||
|
||||
// Skip join tables or non-entity types if necessary
|
||||
if (entityType.Name.Contains("Dettaglio") || entityType.Name.Contains("Link"))
|
||||
continue;
|
||||
|
||||
var datasetId = ToCamelCase(entityType.Name);
|
||||
|
||||
// Default values
|
||||
var name = SplitCamelCase(entityType.Name);
|
||||
var description = $"Dataset {entityType.Name}";
|
||||
var icon = GetIconForType(entityType.Name);
|
||||
var category = "Principale";
|
||||
|
||||
// Determine category from namespace if not in metadata
|
||||
if (entityType.Namespace != null)
|
||||
{
|
||||
var parts = entityType.Namespace.Split('.');
|
||||
if (parts.Length > 3 && parts[2] == "Entities")
|
||||
{
|
||||
category = parts[3];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply metadata if available
|
||||
if (_datasetMetadata.TryGetValue(entityType.Name, out var meta))
|
||||
{
|
||||
name = meta.Name;
|
||||
description = meta.Description;
|
||||
icon = meta.Icon;
|
||||
category = meta.Category;
|
||||
}
|
||||
|
||||
datasets.Add(new DatasetTypeDto
|
||||
{
|
||||
Id = datasetId,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Icon = icon,
|
||||
Category = category,
|
||||
IsVirtual = false
|
||||
});
|
||||
}
|
||||
|
||||
return datasets.OrderBy(d => d.Category).ThenBy(d => d.Name).ToList();
|
||||
}
|
||||
|
||||
public DataSchemaDto? GetSchema(string datasetId)
|
||||
{
|
||||
var type = GetTypeForDataset(datasetId);
|
||||
if (type == null) return null;
|
||||
|
||||
var fields = new List<DataFieldDto>();
|
||||
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead);
|
||||
|
||||
foreach (var prop in props)
|
||||
{
|
||||
// Skip collections for fields, handle them as child collections if needed
|
||||
if (typeof(IEnumerable).IsAssignableFrom(prop.PropertyType) && prop.PropertyType != typeof(string))
|
||||
continue;
|
||||
|
||||
// Skip complex types that are not mapped as owned types (simplification)
|
||||
if (!IsSimpleType(prop.PropertyType))
|
||||
continue;
|
||||
|
||||
fields.Add(new DataFieldDto
|
||||
{
|
||||
Name = ToCamelCase(prop.Name),
|
||||
Label = SplitCamelCase(prop.Name),
|
||||
Type = MapType(prop.PropertyType),
|
||||
Group = "Fields"
|
||||
});
|
||||
}
|
||||
|
||||
return new DataSchemaDto
|
||||
{
|
||||
EntityType = type.Name,
|
||||
DatasetId = datasetId,
|
||||
Fields = fields,
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<EntityListItemDto>> GetEntities(string datasetId, string? search, int limit, int offset)
|
||||
{
|
||||
var type = GetTypeForDataset(datasetId);
|
||||
if (type == null) return new List<EntityListItemDto>();
|
||||
|
||||
var method = this.GetType().GetMethod("GetEntitiesGeneric", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.MakeGenericMethod(type);
|
||||
|
||||
var task = (Task<List<EntityListItemDto>>)method.Invoke(this, new object[] { search, limit, offset })!;
|
||||
return await task;
|
||||
}
|
||||
|
||||
public async Task<int> CountEntities(string datasetId, string? search)
|
||||
{
|
||||
var type = GetTypeForDataset(datasetId);
|
||||
if (type == null) return 0;
|
||||
|
||||
var method = this.GetType().GetMethod("CountEntitiesGeneric", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.MakeGenericMethod(type);
|
||||
|
||||
var task = (Task<int>)method.Invoke(this, new object[] { search })!;
|
||||
return await task;
|
||||
}
|
||||
|
||||
public async Task<object?> LoadEntity(string datasetId, int id)
|
||||
{
|
||||
var type = GetTypeForDataset(datasetId);
|
||||
if (type == null) return null;
|
||||
|
||||
var method = this.GetType().GetMethod("LoadEntityGeneric", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.MakeGenericMethod(type);
|
||||
|
||||
var task = (Task<object?>)method.Invoke(this, new object[] { id })!;
|
||||
return await task;
|
||||
}
|
||||
|
||||
// Generic implementations
|
||||
private async Task<object?> LoadEntityGeneric<T>(int id) where T : class
|
||||
{
|
||||
var query = _context.Set<T>().AsQueryable();
|
||||
|
||||
// Eager load all navigation properties
|
||||
var entityType = _context.Model.FindEntityType(typeof(T));
|
||||
if (entityType != null)
|
||||
{
|
||||
foreach (var nav in entityType.GetNavigations())
|
||||
{
|
||||
query = query.Include(nav.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(e => EF.Property<int>(e, "Id") == id);
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetEntitiesGeneric<T>(string? search, int limit, int offset) where T : class
|
||||
{
|
||||
var query = _context.Set<T>().AsQueryable();
|
||||
|
||||
// Apply search if possible
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var predicate = BuildSearchPredicate<T>(search);
|
||||
if (predicate != null)
|
||||
{
|
||||
query = query.Where(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
// Order by Label property if available (Alphabetical), otherwise by Id Descending
|
||||
var labelProp = GetLabelProperty(typeof(T));
|
||||
if (labelProp != null)
|
||||
{
|
||||
query = query.OrderBy(e => EF.Property<string>(e, labelProp.Name));
|
||||
}
|
||||
else
|
||||
{
|
||||
var idProp = typeof(T).GetProperty("Id");
|
||||
if (idProp != null)
|
||||
{
|
||||
query = query.OrderByDescending(e => EF.Property<object>(e, "Id"));
|
||||
}
|
||||
}
|
||||
|
||||
var list = await query.Skip(offset).Take(limit).ToListAsync();
|
||||
return list.Select(item => MapToListItem(item)).ToList();
|
||||
}
|
||||
|
||||
private async Task<int> CountEntitiesGeneric<T>(string? search) where T : class
|
||||
{
|
||||
var query = _context.Set<T>().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var predicate = BuildSearchPredicate<T>(search);
|
||||
if (predicate != null)
|
||||
{
|
||||
query = query.Where(predicate);
|
||||
}
|
||||
}
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
// Helpers
|
||||
private Type? GetTypeForDataset(string datasetId)
|
||||
{
|
||||
// Case insensitive match
|
||||
var properties = typeof(ZentralDbContext).GetProperties()
|
||||
.Where(p => p.PropertyType.IsGenericType &&
|
||||
p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>));
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
var entityType = prop.PropertyType.GetGenericArguments()[0];
|
||||
if (entityType.Name.Equals(datasetId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return entityType;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Expression<Func<T, bool>>? BuildSearchPredicate<T>(string search)
|
||||
{
|
||||
var parameter = Expression.Parameter(typeof(T), "e");
|
||||
var searchLower = Expression.Constant(search.ToLower());
|
||||
|
||||
var stringProps = typeof(T).GetProperties()
|
||||
.Where(p => p.PropertyType == typeof(string) && p.CanRead)
|
||||
.Take(3) // Limit to first 3 string properties to avoid huge queries
|
||||
.ToList();
|
||||
|
||||
if (!stringProps.Any()) return null;
|
||||
|
||||
Expression? body = null;
|
||||
var containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) });
|
||||
var toLowerMethod = typeof(string).GetMethod("ToLower", Type.EmptyTypes);
|
||||
|
||||
foreach (var prop in stringProps)
|
||||
{
|
||||
// e.Prop
|
||||
var propExp = Expression.Property(parameter, prop);
|
||||
// e.Prop != null
|
||||
var notNull = Expression.NotEqual(propExp, Expression.Constant(null));
|
||||
// e.Prop.ToLower()
|
||||
var toLower = Expression.Call(propExp, toLowerMethod!);
|
||||
// e.Prop.ToLower().Contains(search)
|
||||
var contains = Expression.Call(toLower, containsMethod!, searchLower);
|
||||
// e.Prop != null && e.Prop.ToLower().Contains(search)
|
||||
var condition = Expression.AndAlso(notNull, contains);
|
||||
|
||||
body = body == null ? condition : Expression.OrElse(body, condition);
|
||||
}
|
||||
|
||||
return body == null ? null : Expression.Lambda<Func<T, bool>>(body, parameter);
|
||||
}
|
||||
|
||||
private EntityListItemDto MapToListItem(object item)
|
||||
{
|
||||
var type = item.GetType();
|
||||
var idProp = type.GetProperty("Id");
|
||||
var id = idProp?.GetValue(item) as int? ?? 0;
|
||||
|
||||
// Use the best label property
|
||||
var labelProp = GetLabelProperty(type);
|
||||
var label = labelProp?.GetValue(item)?.ToString();
|
||||
|
||||
// Fallback to Codice if label is empty and we haven't tried Codice yet
|
||||
if (string.IsNullOrWhiteSpace(label) && labelProp?.Name != "Codice")
|
||||
{
|
||||
var codiceProp = type.GetProperty("Codice", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
label = codiceProp?.GetValue(item)?.ToString();
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
{
|
||||
label = $"Item {id}";
|
||||
}
|
||||
|
||||
// Description: try to find a secondary useful field
|
||||
var description = "";
|
||||
if (labelProp?.Name != "Descrizione")
|
||||
{
|
||||
var descProp = type.GetProperty("Descrizione", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
description = descProp?.GetValue(item)?.ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(description) && labelProp?.Name != "Codice")
|
||||
{
|
||||
var codiceProp = type.GetProperty("Codice", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
description = codiceProp?.GetValue(item)?.ToString();
|
||||
}
|
||||
|
||||
return new EntityListItemDto
|
||||
{
|
||||
Id = id,
|
||||
Label = label,
|
||||
Description = description ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
private PropertyInfo? GetLabelProperty(Type type)
|
||||
{
|
||||
var candidates = new[] { "RagioneSociale", "Nome", "Descrizione", "Titolo", "Codice", "Name", "Description", "Code", "Title" };
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var prop = type.GetProperty(candidate, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
if (prop != null && prop.PropertyType == typeof(string))
|
||||
{
|
||||
return prop;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? GetStringProp(object item, string propName)
|
||||
{
|
||||
var prop = item.GetType().GetProperty(propName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
return prop?.GetValue(item)?.ToString();
|
||||
}
|
||||
|
||||
private bool IsSimpleType(Type type)
|
||||
{
|
||||
return type.IsPrimitive ||
|
||||
type.IsEnum ||
|
||||
type == typeof(string) ||
|
||||
type == typeof(decimal) ||
|
||||
type == typeof(DateTime) ||
|
||||
type == typeof(DateTime?) ||
|
||||
type == typeof(int?) ||
|
||||
type == typeof(decimal?) ||
|
||||
type == typeof(bool) ||
|
||||
type == typeof(bool?);
|
||||
}
|
||||
|
||||
private string MapType(Type type)
|
||||
{
|
||||
if (type == typeof(string)) return "string";
|
||||
if (type == typeof(int) || type == typeof(int?) ||
|
||||
type == typeof(long) || type == typeof(long?) ||
|
||||
type == typeof(short) || type == typeof(short?)) return "number";
|
||||
if (type == typeof(decimal) || type == typeof(decimal?) ||
|
||||
type == typeof(double) || type == typeof(double?) ||
|
||||
type == typeof(float) || type == typeof(float?)) return "currency"; // or number
|
||||
if (type == typeof(DateTime) || type == typeof(DateTime?)) return "date";
|
||||
if (type == typeof(bool) || type == typeof(bool?)) return "boolean";
|
||||
return "string";
|
||||
}
|
||||
|
||||
private string SplitCamelCase(string input)
|
||||
{
|
||||
return System.Text.RegularExpressions.Regex.Replace(input, "([A-Z])", " $1", System.Text.RegularExpressions.RegexOptions.Compiled).Trim();
|
||||
}
|
||||
|
||||
private string ToCamelCase(string str)
|
||||
{
|
||||
if (string.IsNullOrEmpty(str) || char.IsLower(str[0]))
|
||||
return str;
|
||||
return char.ToLower(str[0]) + str.Substring(1);
|
||||
}
|
||||
|
||||
private string GetIconForType(string typeName)
|
||||
{
|
||||
typeName = typeName.ToLower();
|
||||
if (typeName.Contains("evento")) return "event";
|
||||
if (typeName.Contains("cliente") || typeName.Contains("persona")) return "people";
|
||||
if (typeName.Contains("location") || typeName.Contains("indirizzo")) return "place";
|
||||
if (typeName.Contains("articolo") || typeName.Contains("prodotto") || typeName.Contains("magazzino")) return "inventory";
|
||||
if (typeName.Contains("risorsa") || typeName.Contains("dipendente")) return "person";
|
||||
if (typeName.Contains("tipo") || typeName.Contains("categoria")) return "category";
|
||||
return "table_chart";
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user