Compare commits
19 Commits
82d2680f5b
...
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 |
27
.agent/rules/customizations-folders.md
Normal file
27
.agent/rules/customizations-folders.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
trigger: model_decision
|
||||
description: Quando è richiesta una feature specifica per un cliente, non inerente allo standard
|
||||
---
|
||||
|
||||
usa ./docs/development/devlog/customizations per tenere traccia di tutti i piani di lavoro custom e il loro attuale stato singolarmente, crea qui dentro i log delle lavorazioni ed il lavoro fatto, da fare e suggerito per ogni piano di sviluppo, usa il formato "yyyy-mm-dd-hh24miss_descrizione_brevissima".
|
||||
|
||||
usa ./docs/development per tenere un file ZENTRAL_CUSTOM.md riassuntivo con link ai file specifici dentro ./docs/development/devlog/customizations e una breve sintesi specificando che tipo di sviluppo si è concluso o si sta lavorando.
|
||||
|
||||
## Struttura Modulare del Progetto Custom
|
||||
|
||||
Per ogni modulo custom specificatamente sviluppato per una richiesta cliente è necessario prima trovare il miglior modo per integrare questo modulo custom il più possibile con i moduli esistenti, evitando di duplicare il codice e permettendo di scrivere meno codice possibile.
|
||||
|
||||
### Backend (.NET)
|
||||
- **API Controllers**: `src/backend/Zentral.API/Modules/Custom/[NomeModulo]/Controllers/`
|
||||
- I controller devono avere il namespace `Zentral.API.Modules.[NomeModulo].Controllers`.
|
||||
- Le rotte devono seguire il pattern `api/custom/[nome-modulo]/[controller]`.
|
||||
- **Entities**: `src/backend/Zentral.Domain/Entities/Custom/[NomeModulo]/`
|
||||
- Le entità devono avere il namespace `Zentral.Domain.Entities.Custom.[NomeModulo]`.
|
||||
|
||||
### Frontend (React)
|
||||
- **Moduli**: `src/frontend/src/modules/custom/[nome-modulo]/`
|
||||
- **Pagine**: `src/frontend/src/modules/custom/[nome-modulo]/pages/`
|
||||
- **Componenti**: `src/frontend/src/modules/custom/[nome-modulo]/components/`
|
||||
- **Rotte**: `src/frontend/src/modules/custom/[nome-modulo]/routes.tsx`
|
||||
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||
@@ -27,4 +27,4 @@ Il progetto segue una rigorosa struttura modulare sia per il backend che per il
|
||||
- **Componenti**: `src/frontend/src/modules/[nome-modulo]/components/`
|
||||
- **Rotte**: `src/frontend/src/modules/[nome-modulo]/routes.tsx`
|
||||
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||
@@ -2,6 +2,8 @@
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Produci sempre prima il piano di implementazione nelle cartelle dedicate e proponi di default all'utente di visionarlo, se l'utente specifica di voler andare avanti, prosegui con l'implementazione del piano senza fermarti; aggiorna il piano man mano che viene sviluppato.
|
||||
|
||||
Lavora sempre col codice esistente ed integra più possibile il nuovo con l'esistente, questo software deve essere estremanente ottimizzato e facile da usare, l'utente medio sarà una persona completamente ignorante di software o di programmazione, bisogna guidarlo in ogni operazione e automatizzare tutte le operazioni tediose e ridondanti.
|
||||
|
||||
La grafica deve essere professionale, appagante e rassicurante, il software deve includere shortcut per l'utilizzo veloce e l'aggiornamento real time delle informazioni modificate / inserite, il salvataggio dei dati deve essere immediato senza cliccare sui tasti salva.
|
||||
|
||||
@@ -12,6 +12,8 @@ Il software si chiama Zentral e, tramite diverse applicazioni, si occupa di gest
|
||||
- magazzino (Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte)
|
||||
- HR (o personale) (Gestione personale, contratti, pagamenti, assenze, rimborsi e analisi personale)
|
||||
- report e stampe (Gestione report, creazione e analisi report)
|
||||
- comunicazioni (Gestione invio mail, chat interna, condivisione risorse del gestionale ad interni ed esterni)
|
||||
- corsi e formazione (Gestione corsi di formazione, erogazione corsi, tracciabilità scadenze)
|
||||
|
||||
mostra statistiche grafiche per ogni applicazione nella dashboard dell'applicazione.
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
||||
|
||||
- [2025-12-02 Rebranding Apollinare to Zentral](./log/2025-12-02_rebranding.md) - **Completato**
|
||||
- Rinomina completa del progetto (Backend & Frontend).
|
||||
- [2025-12-13 Mandatory Training Specs](./devlog/2025-12-13-164500_mandatory_training_specs.md) - **Completato**
|
||||
- Definizione specifiche funzionali e Implementazione modulo (Backend + Frontend).
|
||||
- [Log Implementazione](./devlog/2025-12-13-170000_mandatory_training_implementation.md)
|
||||
- [2025-12-03 UI Restructuring](./devlog/2025-12-03_ui_restructuring.md) - **Completato**
|
||||
- Ristrutturazione interfaccia: Sidebar a 2 livelli, Tabs, SearchBar.
|
||||
- [2025-12-03 Backend Fix](./devlog/2025-12-03_backend_fix.md) - **Completato**
|
||||
@@ -20,7 +23,7 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
||||
- Implementazione modulo Gestione Eventi: strutturazione frontend, integrazione funzionalità e attivazione store.
|
||||
- [Event Module Development](./devlog/event-module.md) - Implementazione modulo eventi
|
||||
- [Menu Refactoring](./devlog/menu-refactoring.md) - Riorganizzazione menu e moduli (Dashboard, Clienti, Articoli, Risorse)
|
||||
- [2025-12-03 Implementazione Modulo Personale](./devlog/2025-12-03_implementazione_modulo_personale.md) - **In Corso**
|
||||
- [2025-12-03 Implementazione Modulo Personale](./devlog/2025-12-03_implementazione_modulo_personale.md) - **Completato**
|
||||
- Implementazione entità, API e Frontend per gestione Personale (Dipendenti, Contratti, Assenze, Pagamenti).
|
||||
- [2025-12-04 Zentral Dashboard and Menu Cleanup](./devlog/2025-12-04-023000_zentral_dashboard.md) - **Completato**
|
||||
- Pulizia menu Zentral (rimozione voci ridondanti) e creazione nuova Dashboard principale con riepilogo moduli attivi.
|
||||
@@ -32,3 +35,27 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
||||
- 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.
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -12,11 +12,13 @@ public class ReportsController : ControllerBase
|
||||
{
|
||||
private readonly ReportGeneratorService _reportGenerator;
|
||||
private readonly ZentralDbContext _context;
|
||||
private readonly SchemaDiscoveryService _schemaDiscovery;
|
||||
|
||||
public ReportsController(ReportGeneratorService reportGenerator, ZentralDbContext context)
|
||||
public ReportsController(ReportGeneratorService reportGenerator, ZentralDbContext context, SchemaDiscoveryService schemaDiscovery)
|
||||
{
|
||||
_reportGenerator = reportGenerator;
|
||||
_context = context;
|
||||
_schemaDiscovery = schemaDiscovery;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,26 +128,7 @@ public class ReportsController : ControllerBase
|
||||
[HttpGet("datasets")]
|
||||
public async Task<ActionResult<List<DatasetTypeDto>>> GetAvailableDatasets()
|
||||
{
|
||||
var datasets = new List<DatasetTypeDto>
|
||||
{
|
||||
// Dataset principali
|
||||
new() { Id = "evento", Name = "Evento", Description = "Dati evento completi con cliente, location, ospiti, costi e risorse", Icon = "event", Category = "Principale" },
|
||||
new() { Id = "cliente", Name = "Cliente", Description = "Anagrafica clienti completa", Icon = "people", Category = "Principale" },
|
||||
new() { Id = "location", Name = "Location", Description = "Sedi e location eventi", Icon = "place", Category = "Principale" },
|
||||
new() { Id = "articolo", Name = "Articolo", Description = "Catalogo articoli e materiali", Icon = "inventory", Category = "Principale" },
|
||||
new() { Id = "risorsa", Name = "Risorsa", Description = "Staff e personale", Icon = "person", Category = "Principale" },
|
||||
|
||||
// Dataset di lookup/configurazione
|
||||
new() { Id = "tipoEvento", Name = "Tipo Evento", Description = "Tipologie di evento (matrimonio, compleanno, etc.)", Icon = "category", Category = "Configurazione" },
|
||||
new() { Id = "tipoOspite", Name = "Tipo Ospite", Description = "Tipologie di ospiti (adulti, bambini, etc.)", Icon = "groups", Category = "Configurazione" },
|
||||
new() { Id = "categoria", Name = "Categoria Articoli", Description = "Categorie articoli con coefficienti di calcolo", Icon = "folder", Category = "Configurazione" },
|
||||
new() { Id = "tipoRisorsa", Name = "Tipo Risorsa", Description = "Tipologie di risorse (cameriere, cuoco, etc.)", Icon = "badge", Category = "Configurazione" },
|
||||
new() { Id = "tipoMateriale", Name = "Tipo Materiale", Description = "Tipologie di materiali", Icon = "category", Category = "Configurazione" },
|
||||
|
||||
// Dataset lista (per report con elenchi)
|
||||
new() { Id = "listaEventi", Name = "Lista Eventi", Description = "Elenco eventi per report multipli", Icon = "list", Category = "Liste" },
|
||||
new() { Id = "listaArticoli", Name = "Lista Articoli", Description = "Elenco articoli per catalogo", Icon = "list", Category = "Liste" },
|
||||
};
|
||||
var datasets = _schemaDiscovery.GetAvailableDatasets();
|
||||
|
||||
// Aggiungi Virtual Dataset dal database
|
||||
var virtualDatasets = await _context.VirtualDatasets
|
||||
@@ -173,7 +156,8 @@ public class ReportsController : ControllerBase
|
||||
[HttpGet("datasets/categories")]
|
||||
public async Task<ActionResult<List<string>>> GetDatasetCategories()
|
||||
{
|
||||
var baseCategories = new List<string> { "Principale", "Configurazione", "Liste" };
|
||||
var datasets = _schemaDiscovery.GetAvailableDatasets();
|
||||
var baseCategories = datasets.Select(d => d.Category).Distinct().ToList();
|
||||
|
||||
// Aggiungi categorie dai Virtual Dataset
|
||||
var virtualCategories = await _context.VirtualDatasets
|
||||
@@ -187,6 +171,8 @@ public class ReportsController : ControllerBase
|
||||
if (!baseCategories.Contains(cat))
|
||||
baseCategories.Add(cat);
|
||||
}
|
||||
|
||||
baseCategories.Sort();
|
||||
|
||||
return baseCategories;
|
||||
}
|
||||
@@ -207,7 +193,7 @@ public class ReportsController : ControllerBase
|
||||
return schema;
|
||||
}
|
||||
|
||||
var staticSchema = GetSchemaForDataset(datasetId);
|
||||
var staticSchema = _schemaDiscovery.GetSchema(datasetId);
|
||||
if (staticSchema == null)
|
||||
return NotFound($"Dataset '{datasetId}' not found");
|
||||
return staticSchema;
|
||||
@@ -230,22 +216,7 @@ public class ReportsController : ControllerBase
|
||||
return await GetVirtualDatasetEntities(virtualName, search, limit, offset);
|
||||
}
|
||||
|
||||
var entities = datasetId.ToLower() switch
|
||||
{
|
||||
"evento" => await GetEventiEntities(search, limit, offset),
|
||||
"cliente" => await GetClientiEntities(search, limit, offset),
|
||||
"location" => await GetLocationEntities(search, limit, offset),
|
||||
"articolo" => await GetArticoliEntities(search, limit, offset),
|
||||
"risorsa" => await GetRisorseEntities(search, limit, offset),
|
||||
"tipoevento" => await GetTipiEventoEntities(search, limit, offset),
|
||||
"tipoospite" => await GetTipiOspiteEntities(search, limit, offset),
|
||||
"categoria" => await GetCategorieEntities(search, limit, offset),
|
||||
"tiporisorsa" => await GetTipiRisorsaEntities(search, limit, offset),
|
||||
"tipomateriale" => await GetTipiMaterialeEntities(search, limit, offset),
|
||||
_ => new List<EntityListItemDto>()
|
||||
};
|
||||
|
||||
return entities;
|
||||
return await _schemaDiscovery.GetEntities(datasetId, search, limit, offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -254,358 +225,10 @@ public class ReportsController : ControllerBase
|
||||
[HttpGet("datasets/{datasetId}/count")]
|
||||
public async Task<ActionResult<int>> GetEntityCount(string datasetId, [FromQuery] string? search = null)
|
||||
{
|
||||
var count = datasetId.ToLower() switch
|
||||
{
|
||||
"evento" => await CountEventi(search),
|
||||
"cliente" => await CountClienti(search),
|
||||
"location" => await CountLocation(search),
|
||||
"articolo" => await CountArticoli(search),
|
||||
"risorsa" => await CountRisorse(search),
|
||||
"tipoevento" => await _context.TipiEvento.CountAsync(t => t.Attivo),
|
||||
"tipoospite" => await _context.TipiOspite.CountAsync(t => t.Attivo),
|
||||
"categoria" => await _context.CodiciCategoria.CountAsync(c => c.Attivo),
|
||||
"tiporisorsa" => await _context.TipiRisorsa.CountAsync(t => t.Attivo),
|
||||
"tipomateriale" => await _context.TipiMateriale.CountAsync(t => t.Attivo),
|
||||
_ => 0
|
||||
};
|
||||
|
||||
return count;
|
||||
return await _schemaDiscovery.CountEntities(datasetId, search);
|
||||
}
|
||||
|
||||
#region Entity Queries
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetEventiEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.Eventi
|
||||
.Include(e => e.Cliente)
|
||||
.Include(e => e.Location)
|
||||
.Include(e => e.TipoEvento)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(e =>
|
||||
(e.Codice != null && e.Codice.ToLower().Contains(search)) ||
|
||||
(e.Cliente != null && e.Cliente.RagioneSociale.ToLower().Contains(search)) ||
|
||||
(e.Location != null && e.Location.Nome.ToLower().Contains(search)));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(e => e.DataEvento)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(e => new EntityListItemDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Label = $"{e.Codice ?? $"EVT-{e.Id}"} - {e.DataEvento:dd/MM/yyyy}",
|
||||
Description = $"{e.Cliente!.RagioneSociale ?? "N/D"} @ {e.Location!.Nome ?? "N/D"}",
|
||||
SecondaryInfo = e.TipoEvento != null ? e.TipoEvento.Descrizione : null,
|
||||
Status = e.Stato.ToString()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<int> CountEventi(string? search)
|
||||
{
|
||||
var query = _context.Eventi.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(e =>
|
||||
(e.Codice != null && e.Codice.ToLower().Contains(search)) ||
|
||||
(e.Cliente != null && e.Cliente.RagioneSociale.ToLower().Contains(search)));
|
||||
}
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetClientiEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.Clienti.Where(c => c.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(c =>
|
||||
c.RagioneSociale.ToLower().Contains(search) ||
|
||||
(c.Citta != null && c.Citta.ToLower().Contains(search)) ||
|
||||
(c.Email != null && c.Email.ToLower().Contains(search)));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(c => c.RagioneSociale)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(c => new EntityListItemDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Label = c.RagioneSociale,
|
||||
Description = $"{c.Citta ?? "N/D"} - {c.Telefono ?? "N/D"}",
|
||||
SecondaryInfo = c.Email
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<int> CountClienti(string? search)
|
||||
{
|
||||
var query = _context.Clienti.Where(c => c.Attivo);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(c => c.RagioneSociale.ToLower().Contains(search));
|
||||
}
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetLocationEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.Location.Where(l => l.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(l =>
|
||||
l.Nome.ToLower().Contains(search) ||
|
||||
(l.Citta != null && l.Citta.ToLower().Contains(search)));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(l => l.Nome)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(l => new EntityListItemDto
|
||||
{
|
||||
Id = l.Id,
|
||||
Label = l.Nome,
|
||||
Description = $"{l.Citta ?? "N/D"} ({l.Provincia ?? "N/D"})",
|
||||
SecondaryInfo = l.DistanzaKm.HasValue ? $"{l.DistanzaKm} km" : null
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<int> CountLocation(string? search)
|
||||
{
|
||||
var query = _context.Location.Where(l => l.Attivo);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(l => l.Nome.ToLower().Contains(search));
|
||||
}
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetArticoliEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.Articoli
|
||||
.Include(a => a.Categoria)
|
||||
.Where(a => a.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(a =>
|
||||
a.Codice.ToLower().Contains(search) ||
|
||||
a.Descrizione.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(a => a.Descrizione)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(a => new EntityListItemDto
|
||||
{
|
||||
Id = a.Id,
|
||||
Label = $"{a.Codice} - {a.Descrizione}",
|
||||
Description = $"Disponibile: {a.QtaDisponibile ?? 0}",
|
||||
SecondaryInfo = a.Categoria != null ? a.Categoria.Descrizione : null
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<int> CountArticoli(string? search)
|
||||
{
|
||||
var query = _context.Articoli.Where(a => a.Attivo);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(a =>
|
||||
a.Codice.ToLower().Contains(search) ||
|
||||
a.Descrizione.ToLower().Contains(search));
|
||||
}
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetRisorseEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.Risorse
|
||||
.Include(r => r.TipoRisorsa)
|
||||
.Where(r => r.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(r =>
|
||||
r.Nome.ToLower().Contains(search) ||
|
||||
(r.Cognome != null && r.Cognome.ToLower().Contains(search)));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(r => r.Cognome).ThenBy(r => r.Nome)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(r => new EntityListItemDto
|
||||
{
|
||||
Id = r.Id,
|
||||
Label = $"{r.Nome} {r.Cognome ?? ""}".Trim(),
|
||||
Description = r.Telefono ?? "",
|
||||
SecondaryInfo = r.TipoRisorsa != null ? r.TipoRisorsa.Descrizione : null
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<int> CountRisorse(string? search)
|
||||
{
|
||||
var query = _context.Risorse.Where(r => r.Attivo);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(r =>
|
||||
r.Nome.ToLower().Contains(search) ||
|
||||
(r.Cognome != null && r.Cognome.ToLower().Contains(search)));
|
||||
}
|
||||
return await query.CountAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetTipiEventoEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.TipiEvento
|
||||
.Include(t => t.TipoPasto)
|
||||
.Where(t => t.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(t =>
|
||||
t.Codice.ToLower().Contains(search) ||
|
||||
t.Descrizione.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(t => t.Descrizione)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(t => new EntityListItemDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Label = t.Descrizione,
|
||||
Description = $"Codice: {t.Codice}",
|
||||
SecondaryInfo = t.TipoPasto != null ? t.TipoPasto.Descrizione : null
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetTipiOspiteEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.TipiOspite.Where(t => t.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(t =>
|
||||
t.Codice.ToLower().Contains(search) ||
|
||||
t.Descrizione.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(t => t.Descrizione)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(t => new EntityListItemDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Label = t.Descrizione,
|
||||
Description = $"Codice: {t.Codice}"
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetCategorieEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.CodiciCategoria.Where(c => c.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(c =>
|
||||
c.Codice.ToLower().Contains(search) ||
|
||||
c.Descrizione.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(c => c.Descrizione)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(c => new EntityListItemDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Label = c.Descrizione,
|
||||
Description = $"Codice: {c.Codice}",
|
||||
SecondaryInfo = $"Coeff: A={c.CoeffA:F2}, B={c.CoeffB:F2}, S={c.CoeffS:F2}"
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetTipiRisorsaEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.TipiRisorsa.Where(t => t.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(t =>
|
||||
t.Codice.ToLower().Contains(search) ||
|
||||
t.Descrizione.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(t => t.Descrizione)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(t => new EntityListItemDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Label = t.Descrizione,
|
||||
Description = $"Codice: {t.Codice}"
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<List<EntityListItemDto>> GetTipiMaterialeEntities(string? search, int limit, int offset)
|
||||
{
|
||||
var query = _context.TipiMateriale.Where(t => t.Attivo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
search = search.ToLower();
|
||||
query = query.Where(t =>
|
||||
t.Codice.ToLower().Contains(search) ||
|
||||
t.Descrizione.ToLower().Contains(search));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(t => t.Descrizione)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(t => new EntityListItemDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Label = t.Descrizione,
|
||||
Description = $"Codice: {t.Codice}"
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task<Dictionary<string, object>> BuildDataContextAsync(List<DataSourceSelection> dataSources)
|
||||
{
|
||||
@@ -642,38 +265,7 @@ public class ReportsController : ControllerBase
|
||||
|
||||
private async Task<object?> LoadEntityDataAsync(string datasetId, int entityId)
|
||||
{
|
||||
return datasetId.ToLower() switch
|
||||
{
|
||||
"evento" => await _context.Eventi
|
||||
.Include(e => e.Cliente)
|
||||
.Include(e => e.Location)
|
||||
.Include(e => e.TipoEvento).ThenInclude(t => t!.TipoPasto)
|
||||
.Include(e => e.DettagliOspiti).ThenInclude(d => d.TipoOspite)
|
||||
.Include(e => e.DettagliPrelievo).ThenInclude(d => d.Articolo).ThenInclude(a => a!.Categoria)
|
||||
.Include(e => e.DettagliRisorse).ThenInclude(d => d.Risorsa).ThenInclude(r => r!.TipoRisorsa)
|
||||
.Include(e => e.Acconti)
|
||||
.Include(e => e.AltriCosti)
|
||||
.Include(e => e.Degustazioni)
|
||||
.FirstOrDefaultAsync(e => e.Id == entityId),
|
||||
|
||||
"cliente" => await _context.Clienti.FindAsync(entityId),
|
||||
"location" => await _context.Location.FindAsync(entityId),
|
||||
"articolo" => await _context.Articoli
|
||||
.Include(a => a.Categoria)
|
||||
.Include(a => a.TipoMateriale)
|
||||
.FirstOrDefaultAsync(a => a.Id == entityId),
|
||||
"risorsa" => await _context.Risorse
|
||||
.Include(r => r.TipoRisorsa)
|
||||
.FirstOrDefaultAsync(r => r.Id == entityId),
|
||||
"tipoevento" => await _context.TipiEvento
|
||||
.Include(t => t.TipoPasto)
|
||||
.FirstOrDefaultAsync(t => t.Id == entityId),
|
||||
"tipoospite" => await _context.TipiOspite.FindAsync(entityId),
|
||||
"categoria" => await _context.CodiciCategoria.FindAsync(entityId),
|
||||
"tiporisorsa" => await _context.TipiRisorsa.FindAsync(entityId),
|
||||
"tipomateriale" => await _context.TipiMateriale.FindAsync(entityId),
|
||||
_ => null
|
||||
};
|
||||
return await _schemaDiscovery.LoadEntity(datasetId, entityId);
|
||||
}
|
||||
|
||||
#region Virtual Dataset Support
|
||||
@@ -706,7 +298,7 @@ public class ReportsController : ControllerBase
|
||||
if (source == null) continue;
|
||||
|
||||
// Ottieni lo schema del dataset sorgente per determinare il tipo
|
||||
var sourceSchema = GetSchemaForDataset(source.DatasetId);
|
||||
var sourceSchema = _schemaDiscovery.GetSchema(source.DatasetId);
|
||||
var sourceField = sourceSchema?.Fields.FirstOrDefault(f =>
|
||||
f.Name.Equals(outputField.FieldName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -724,7 +316,7 @@ public class ReportsController : ControllerBase
|
||||
// Se non ci sono OutputFields, includi tutti i campi di tutte le sorgenti
|
||||
foreach (var source in config.Sources)
|
||||
{
|
||||
var sourceSchema = GetSchemaForDataset(source.DatasetId);
|
||||
var sourceSchema = _schemaDiscovery.GetSchema(source.DatasetId);
|
||||
if (sourceSchema == null) continue;
|
||||
|
||||
foreach (var field in sourceSchema.Fields)
|
||||
@@ -773,15 +365,7 @@ public class ReportsController : ControllerBase
|
||||
if (primarySource == null) return new List<EntityListItemDto>();
|
||||
|
||||
// Restituisce le entità del dataset primario
|
||||
return primarySource.DatasetId.ToLower() switch
|
||||
{
|
||||
"evento" => await GetEventiEntities(search, limit, offset),
|
||||
"cliente" => await GetClientiEntities(search, limit, offset),
|
||||
"location" => await GetLocationEntities(search, limit, offset),
|
||||
"articolo" => await GetArticoliEntities(search, limit, offset),
|
||||
"risorsa" => await GetRisorseEntities(search, limit, offset),
|
||||
_ => new List<EntityListItemDto>()
|
||||
};
|
||||
return await _schemaDiscovery.GetEntities(primarySource.DatasetId, search, limit, offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -881,349 +465,7 @@ public class ReportsController : ControllerBase
|
||||
|
||||
#endregion
|
||||
|
||||
private static DataSchemaDto? GetSchemaForDataset(string datasetId)
|
||||
{
|
||||
return datasetId.ToLower() switch
|
||||
{
|
||||
"evento" => GetEventoSchema(),
|
||||
"cliente" => GetClienteSchema(),
|
||||
"location" => GetLocationSchema(),
|
||||
"articolo" => GetArticoloSchema(),
|
||||
"risorsa" => GetRisorsaSchema(),
|
||||
"tipoevento" => GetTipoEventoSchema(),
|
||||
"tipoospite" => GetTipoOspiteSchema(),
|
||||
"categoria" => GetCategoriaSchema(),
|
||||
"tiporisorsa" => GetTipoRisorsaSchema(),
|
||||
"tipomateriale" => GetTipoMaterialeSchema(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
#region Schema Definitions
|
||||
|
||||
private static DataSchemaDto GetEventoSchema() => new()
|
||||
{
|
||||
EntityType = "Evento",
|
||||
DatasetId = "evento",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
// Campi base
|
||||
new() { Name = "id", Type = "number", Label = "ID", Group = "Base" },
|
||||
new() { Name = "codice", Type = "string", Label = "Codice Evento", Group = "Base" },
|
||||
new() { Name = "dataEvento", Type = "date", Label = "Data Evento", Group = "Base" },
|
||||
new() { Name = "oraInizio", Type = "time", Label = "Ora Inizio", Group = "Base" },
|
||||
new() { Name = "oraFine", Type = "time", Label = "Ora Fine", Group = "Base" },
|
||||
new() { Name = "descrizione", Type = "string", Label = "Descrizione", Group = "Base" },
|
||||
new() { Name = "stato", Type = "number", Label = "Stato (0=Scheda, 10=Preventivo, 20=Confermato)", Group = "Base" },
|
||||
new() { Name = "confermato", Type = "boolean", Label = "Confermato", Group = "Base" },
|
||||
|
||||
// Ospiti
|
||||
new() { Name = "numeroOspiti", Type = "number", Label = "Numero Ospiti Totale", Group = "Ospiti" },
|
||||
new() { Name = "numeroOspitiAdulti", Type = "number", Label = "Ospiti Adulti", Group = "Ospiti" },
|
||||
new() { Name = "numeroOspitiBambini", Type = "number", Label = "Ospiti Bambini", Group = "Ospiti" },
|
||||
new() { Name = "numeroOspitiSeduti", Type = "number", Label = "Ospiti Seduti", Group = "Ospiti" },
|
||||
new() { Name = "numeroOspitiBuffet", Type = "number", Label = "Ospiti Buffet", Group = "Ospiti" },
|
||||
|
||||
// Economici
|
||||
new() { Name = "costoTotale", Type = "currency", Label = "Costo Totale", Group = "Economici" },
|
||||
new() { Name = "costoPersona", Type = "currency", Label = "Costo per Persona", Group = "Economici" },
|
||||
new() { Name = "totaleAcconti", Type = "currency", Label = "Totale Acconti", Group = "Economici" },
|
||||
new() { Name = "saldo", Type = "currency", Label = "Saldo da Pagare", Group = "Economici" },
|
||||
new() { Name = "dataScadenzaPreventivo", Type = "date", Label = "Scadenza Preventivo", Group = "Economici" },
|
||||
|
||||
// Note
|
||||
new() { Name = "noteCliente", Type = "string", Label = "Note Cliente", Group = "Note" },
|
||||
new() { Name = "noteInterne", Type = "string", Label = "Note Interne", Group = "Note" },
|
||||
new() { Name = "noteCucina", Type = "string", Label = "Note Cucina", Group = "Note" },
|
||||
new() { Name = "noteAllestimento", Type = "string", Label = "Note Allestimento", Group = "Note" },
|
||||
|
||||
// Cliente (relazione)
|
||||
new() { Name = "cliente.id", Type = "number", Label = "ID Cliente", Group = "Cliente" },
|
||||
new() { Name = "cliente.ragioneSociale", Type = "string", Label = "Ragione Sociale", Group = "Cliente" },
|
||||
new() { Name = "cliente.indirizzo", Type = "string", Label = "Indirizzo Cliente", Group = "Cliente" },
|
||||
new() { Name = "cliente.cap", Type = "string", Label = "CAP Cliente", Group = "Cliente" },
|
||||
new() { Name = "cliente.citta", Type = "string", Label = "Città Cliente", Group = "Cliente" },
|
||||
new() { Name = "cliente.provincia", Type = "string", Label = "Provincia Cliente", Group = "Cliente" },
|
||||
new() { Name = "cliente.telefono", Type = "string", Label = "Telefono Cliente", Group = "Cliente" },
|
||||
new() { Name = "cliente.email", Type = "string", Label = "Email Cliente", Group = "Cliente" },
|
||||
new() { Name = "cliente.pec", Type = "string", Label = "PEC Cliente", Group = "Cliente" },
|
||||
new() { Name = "cliente.codiceFiscale", Type = "string", Label = "Codice Fiscale", Group = "Cliente" },
|
||||
new() { Name = "cliente.partitaIva", Type = "string", Label = "Partita IVA", Group = "Cliente" },
|
||||
new() { Name = "cliente.codiceDestinatario", Type = "string", Label = "Codice SDI", Group = "Cliente" },
|
||||
|
||||
// Location (relazione)
|
||||
new() { Name = "location.id", Type = "number", Label = "ID Location", Group = "Location" },
|
||||
new() { Name = "location.nome", Type = "string", Label = "Nome Location", Group = "Location" },
|
||||
new() { Name = "location.indirizzo", Type = "string", Label = "Indirizzo Location", Group = "Location" },
|
||||
new() { Name = "location.cap", Type = "string", Label = "CAP Location", Group = "Location" },
|
||||
new() { Name = "location.citta", Type = "string", Label = "Città Location", Group = "Location" },
|
||||
new() { Name = "location.provincia", Type = "string", Label = "Provincia Location", Group = "Location" },
|
||||
new() { Name = "location.telefono", Type = "string", Label = "Telefono Location", Group = "Location" },
|
||||
new() { Name = "location.email", Type = "string", Label = "Email Location", Group = "Location" },
|
||||
new() { Name = "location.referente", Type = "string", Label = "Referente Location", Group = "Location" },
|
||||
new() { Name = "location.distanzaKm", Type = "number", Label = "Distanza (km)", Group = "Location" },
|
||||
|
||||
// Tipo Evento (relazione)
|
||||
new() { Name = "tipoEvento.codice", Type = "string", Label = "Codice Tipo Evento", Group = "Tipo Evento" },
|
||||
new() { Name = "tipoEvento.descrizione", Type = "string", Label = "Tipo Evento", Group = "Tipo Evento" },
|
||||
new() { Name = "tipoEvento.tipoPasto.descrizione", Type = "string", Label = "Tipo Pasto", Group = "Tipo Evento" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "dettagliOspiti",
|
||||
Label = "Dettaglio Ospiti",
|
||||
Description = "Breakdown ospiti per tipologia",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "tipoOspite.codice", Type = "string", Label = "Codice Tipo" },
|
||||
new() { Name = "tipoOspite.descrizione", Type = "string", Label = "Tipo Ospite" },
|
||||
new() { Name = "numero", Type = "number", Label = "Numero" },
|
||||
new() { Name = "costoUnitario", Type = "currency", Label = "Costo Unitario" },
|
||||
new() { Name = "sconto", Type = "percent", Label = "Sconto %" },
|
||||
new() { Name = "totale", Type = "currency", Label = "Totale" },
|
||||
new() { Name = "note", Type = "string", Label = "Note" }
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "altriCosti",
|
||||
Label = "Altri Costi",
|
||||
Description = "Costi aggiuntivi dell'evento",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "descrizione", Type = "string", Label = "Descrizione" },
|
||||
new() { Name = "costoUnitario", Type = "currency", Label = "Costo Unitario" },
|
||||
new() { Name = "quantita", Type = "number", Label = "Quantità" },
|
||||
new() { Name = "aliquotaIva", Type = "percent", Label = "Aliquota IVA" },
|
||||
new() { Name = "totale", Type = "currency", Label = "Totale" }
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "dettagliRisorse",
|
||||
Label = "Risorse Assegnate",
|
||||
Description = "Personale assegnato all'evento",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "risorsa.nome", Type = "string", Label = "Nome" },
|
||||
new() { Name = "risorsa.cognome", Type = "string", Label = "Cognome" },
|
||||
new() { Name = "risorsa.telefono", Type = "string", Label = "Telefono" },
|
||||
new() { Name = "risorsa.tipoRisorsa.descrizione", Type = "string", Label = "Ruolo" },
|
||||
new() { Name = "oraInizio", Type = "time", Label = "Ora Inizio" },
|
||||
new() { Name = "oraFine", Type = "time", Label = "Ora Fine" },
|
||||
new() { Name = "oreLavoro", Type = "number", Label = "Ore Lavoro" },
|
||||
new() { Name = "costo", Type = "currency", Label = "Costo" },
|
||||
new() { Name = "note", Type = "string", Label = "Note" }
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "acconti",
|
||||
Label = "Acconti",
|
||||
Description = "Pagamenti anticipati ricevuti",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "descrizione", Type = "string", Label = "Descrizione" },
|
||||
new() { Name = "importo", Type = "currency", Label = "Importo" },
|
||||
new() { Name = "dataPagamento", Type = "date", Label = "Data Pagamento" },
|
||||
new() { Name = "metodoPagamento", Type = "string", Label = "Metodo Pagamento" },
|
||||
new() { Name = "note", Type = "string", Label = "Note" }
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "dettagliPrelievo",
|
||||
Label = "Lista Prelievo",
|
||||
Description = "Articoli necessari per l'evento",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "articolo.codice", Type = "string", Label = "Codice Articolo" },
|
||||
new() { Name = "articolo.descrizione", Type = "string", Label = "Descrizione" },
|
||||
new() { Name = "articolo.categoria.descrizione", Type = "string", Label = "Categoria" },
|
||||
new() { Name = "articolo.unitaMisura", Type = "string", Label = "U.M." },
|
||||
new() { Name = "qtaRichiesta", Type = "number", Label = "Qtà Richiesta" },
|
||||
new() { Name = "qtaCalcolata", Type = "number", Label = "Qtà Calcolata" },
|
||||
new() { Name = "qtaEffettiva", Type = "number", Label = "Qtà Effettiva" },
|
||||
new() { Name = "note", Type = "string", Label = "Note" }
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "degustazioni",
|
||||
Label = "Degustazioni",
|
||||
Description = "Degustazioni programmate",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "data", Type = "date", Label = "Data" },
|
||||
new() { Name = "ora", Type = "time", Label = "Ora" },
|
||||
new() { Name = "numeroPartecipanti", Type = "number", Label = "Partecipanti" },
|
||||
new() { Name = "note", Type = "string", Label = "Note" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetClienteSchema() => new()
|
||||
{
|
||||
EntityType = "Cliente",
|
||||
DatasetId = "cliente",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID", Group = "Base" },
|
||||
new() { Name = "ragioneSociale", Type = "string", Label = "Ragione Sociale", Group = "Base" },
|
||||
new() { Name = "indirizzo", Type = "string", Label = "Indirizzo", Group = "Indirizzo" },
|
||||
new() { Name = "cap", Type = "string", Label = "CAP", Group = "Indirizzo" },
|
||||
new() { Name = "citta", Type = "string", Label = "Città", Group = "Indirizzo" },
|
||||
new() { Name = "provincia", Type = "string", Label = "Provincia", Group = "Indirizzo" },
|
||||
new() { Name = "telefono", Type = "string", Label = "Telefono", Group = "Contatti" },
|
||||
new() { Name = "email", Type = "string", Label = "Email", Group = "Contatti" },
|
||||
new() { Name = "pec", Type = "string", Label = "PEC", Group = "Contatti" },
|
||||
new() { Name = "codiceFiscale", Type = "string", Label = "Codice Fiscale", Group = "Fiscale" },
|
||||
new() { Name = "partitaIva", Type = "string", Label = "Partita IVA", Group = "Fiscale" },
|
||||
new() { Name = "codiceDestinatario", Type = "string", Label = "Codice Destinatario SDI", Group = "Fiscale" },
|
||||
new() { Name = "note", Type = "string", Label = "Note", Group = "Base" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetLocationSchema() => new()
|
||||
{
|
||||
EntityType = "Location",
|
||||
DatasetId = "location",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID", Group = "Base" },
|
||||
new() { Name = "nome", Type = "string", Label = "Nome", Group = "Base" },
|
||||
new() { Name = "indirizzo", Type = "string", Label = "Indirizzo", Group = "Indirizzo" },
|
||||
new() { Name = "cap", Type = "string", Label = "CAP", Group = "Indirizzo" },
|
||||
new() { Name = "citta", Type = "string", Label = "Città", Group = "Indirizzo" },
|
||||
new() { Name = "provincia", Type = "string", Label = "Provincia", Group = "Indirizzo" },
|
||||
new() { Name = "telefono", Type = "string", Label = "Telefono", Group = "Contatti" },
|
||||
new() { Name = "email", Type = "string", Label = "Email", Group = "Contatti" },
|
||||
new() { Name = "referente", Type = "string", Label = "Referente", Group = "Contatti" },
|
||||
new() { Name = "distanzaKm", Type = "number", Label = "Distanza (km)", Group = "Base" },
|
||||
new() { Name = "note", Type = "string", Label = "Note", Group = "Base" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetArticoloSchema() => new()
|
||||
{
|
||||
EntityType = "Articolo",
|
||||
DatasetId = "articolo",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID", Group = "Base" },
|
||||
new() { Name = "codice", Type = "string", Label = "Codice", Group = "Base" },
|
||||
new() { Name = "descrizione", Type = "string", Label = "Descrizione", Group = "Base" },
|
||||
new() { Name = "unitaMisura", Type = "string", Label = "Unità di Misura", Group = "Base" },
|
||||
new() { Name = "qtaDisponibile", Type = "number", Label = "Quantità Disponibile", Group = "Quantità" },
|
||||
new() { Name = "qtaStdA", Type = "number", Label = "Qtà Standard Adulti", Group = "Quantità" },
|
||||
new() { Name = "qtaStdB", Type = "number", Label = "Qtà Standard Buffet", Group = "Quantità" },
|
||||
new() { Name = "qtaStdS", Type = "number", Label = "Qtà Standard Seduti", Group = "Quantità" },
|
||||
new() { Name = "categoria.codice", Type = "string", Label = "Codice Categoria", Group = "Categoria" },
|
||||
new() { Name = "categoria.descrizione", Type = "string", Label = "Categoria", Group = "Categoria" },
|
||||
new() { Name = "categoria.coeffA", Type = "number", Label = "Coefficiente Adulti", Group = "Categoria" },
|
||||
new() { Name = "categoria.coeffB", Type = "number", Label = "Coefficiente Buffet", Group = "Categoria" },
|
||||
new() { Name = "categoria.coeffS", Type = "number", Label = "Coefficiente Seduti", Group = "Categoria" },
|
||||
new() { Name = "tipoMateriale.codice", Type = "string", Label = "Codice Tipo Materiale", Group = "Materiale" },
|
||||
new() { Name = "tipoMateriale.descrizione", Type = "string", Label = "Tipo Materiale", Group = "Materiale" },
|
||||
new() { Name = "note", Type = "string", Label = "Note", Group = "Base" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetRisorsaSchema() => new()
|
||||
{
|
||||
EntityType = "Risorsa",
|
||||
DatasetId = "risorsa",
|
||||
Fields = new List<DataFieldDto>
|
||||
{
|
||||
new() { Name = "id", Type = "number", Label = "ID", Group = "Base" },
|
||||
new() { Name = "nome", Type = "string", Label = "Nome", Group = "Base" },
|
||||
new() { Name = "cognome", Type = "string", Label = "Cognome", Group = "Base" },
|
||||
new() { Name = "telefono", Type = "string", Label = "Telefono", Group = "Contatti" },
|
||||
new() { Name = "email", Type = "string", Label = "Email", Group = "Contatti" },
|
||||
new() { Name = "tipoRisorsa.codice", Type = "string", Label = "Codice Tipo", Group = "Tipo" },
|
||||
new() { Name = "tipoRisorsa.descrizione", Type = "string", Label = "Tipo Risorsa", Group = "Tipo" },
|
||||
new() { Name = "note", Type = "string", Label = "Note", Group = "Base" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetTipoEventoSchema() => new()
|
||||
{
|
||||
EntityType = "Tipo Evento",
|
||||
DatasetId = "tipoEvento",
|
||||
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 = "tipoPasto.codice", Type = "string", Label = "Codice Tipo Pasto" },
|
||||
new() { Name = "tipoPasto.descrizione", Type = "string", Label = "Tipo Pasto" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetTipoOspiteSchema() => new()
|
||||
{
|
||||
EntityType = "Tipo Ospite",
|
||||
DatasetId = "tipoOspite",
|
||||
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" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetCategoriaSchema() => new()
|
||||
{
|
||||
EntityType = "Categoria Articoli",
|
||||
DatasetId = "categoria",
|
||||
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 = "coeffA", Type = "number", Label = "Coefficiente Adulti" },
|
||||
new() { Name = "coeffB", Type = "number", Label = "Coefficiente Buffet" },
|
||||
new() { Name = "coeffS", Type = "number", Label = "Coefficiente Seduti" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetTipoRisorsaSchema() => new()
|
||||
{
|
||||
EntityType = "Tipo Risorsa",
|
||||
DatasetId = "tipoRisorsa",
|
||||
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" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
private static DataSchemaDto GetTipoMaterialeSchema() => new()
|
||||
{
|
||||
EntityType = "Tipo Materiale",
|
||||
DatasetId = "tipoMateriale",
|
||||
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" },
|
||||
},
|
||||
ChildCollections = new List<DataCollectionDto>()
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
// DTOs moved to AprtModels.cs
|
||||
|
||||
@@ -28,6 +28,10 @@ public interface IWarehouseService
|
||||
Task<WarehouseArticleCategory> UpdateCategoryAsync(WarehouseArticleCategory category);
|
||||
Task DeleteCategoryAsync(int id);
|
||||
|
||||
// ===============================================
|
||||
// GRUPPI MERCEOLOGICI
|
||||
// ===============================================
|
||||
|
||||
// ===============================================
|
||||
// MAGAZZINI
|
||||
// ===============================================
|
||||
|
||||
@@ -60,6 +60,7 @@ public class WarehouseService : IWarehouseService
|
||||
if (filter.CategoryId.HasValue)
|
||||
query = query.Where(a => a.CategoryId == filter.CategoryId);
|
||||
|
||||
|
||||
if (filter.IsActive.HasValue)
|
||||
query = query.Where(a => a.IsActive == filter.IsActive);
|
||||
|
||||
@@ -336,6 +337,7 @@ public class WarehouseService : IWarehouseService
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region Magazzini
|
||||
|
||||
public async Task<List<WarehouseLocation>> GetWarehousesAsync(bool includeInactive = false)
|
||||
|
||||
@@ -24,7 +24,8 @@ public class ArticoliController : ControllerBase
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] int? tipoMaterialeId,
|
||||
[FromQuery] int? categoriaId,
|
||||
[FromQuery] bool? attivo)
|
||||
[FromQuery] bool? attivo,
|
||||
[FromQuery] TipoArticolo? tipo)
|
||||
{
|
||||
var query = _context.Articoli
|
||||
.Include(a => a.TipoMateriale)
|
||||
@@ -43,6 +44,9 @@ public class ArticoliController : ControllerBase
|
||||
if (attivo.HasValue)
|
||||
query = query.Where(a => a.Attivo == attivo.Value);
|
||||
|
||||
if (tipo.HasValue)
|
||||
query = query.Where(a => a.Tipo == tipo.Value);
|
||||
|
||||
return await query.OrderBy(a => a.Descrizione).ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ public class ClientiController : ControllerBase
|
||||
{
|
||||
var cliente = await _context.Clienti
|
||||
.Include(c => c.Eventi)
|
||||
.Include(c => c.Contatti)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (cliente == null)
|
||||
@@ -99,4 +100,53 @@ public class ClientiController : ControllerBase
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Contatti Management
|
||||
[HttpGet("{id}/contatti")]
|
||||
public async Task<ActionResult<IEnumerable<ClienteContatto>>> GetContatti(int id)
|
||||
{
|
||||
var contatti = await _context.Contatti
|
||||
.Where(c => c.ClienteId == id)
|
||||
.OrderBy(c => c.Cognome).ThenBy(c => c.Nome)
|
||||
.ToListAsync();
|
||||
return contatti;
|
||||
}
|
||||
|
||||
[HttpPost("{id}/contatti")]
|
||||
public async Task<ActionResult<ClienteContatto>> CreateContatto(int id, ClienteContatto contatto)
|
||||
{
|
||||
if (id != contatto.ClienteId)
|
||||
contatto.ClienteId = id;
|
||||
|
||||
contatto.CreatedAt = DateTime.UtcNow;
|
||||
_context.Contatti.Add(contatto);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(contatto);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/contatti/{contattoId}")]
|
||||
public async Task<IActionResult> UpdateContatto(int id, int contattoId, ClienteContatto contatto)
|
||||
{
|
||||
if (id != contatto.ClienteId || contattoId != contatto.Id)
|
||||
return BadRequest();
|
||||
|
||||
contatto.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Entry(contatto).State = EntityState.Modified;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/contatti/{contattoId}")]
|
||||
public async Task<IActionResult> DeleteContatto(int id, int contattoId)
|
||||
{
|
||||
var contatto = await _context.Contatti.FindAsync(contattoId);
|
||||
if (contatto == null || contatto.ClienteId != id)
|
||||
return NotFound();
|
||||
|
||||
_context.Contatti.Remove(contatto);
|
||||
await _context.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,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>
|
||||
@@ -365,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
|
||||
});
|
||||
}
|
||||
@@ -379,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)
|
||||
@@ -427,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;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ using Zentral.API.Apps.Warehouse.Services;
|
||||
using Zentral.API.Apps.Purchases.Services;
|
||||
using Zentral.API.Apps.Sales.Services;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Zentral.API.Apps.Production.Services;
|
||||
using Zentral.Infrastructure.Data;
|
||||
using Zentral.Infrastructure.Services;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -19,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<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();
|
||||
|
||||
|
||||
@@ -521,6 +521,34 @@ public class AppService
|
||||
RoutePath = "/report-designer",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "communications",
|
||||
Name = "Comunicazioni",
|
||||
Description = "Gestione invio mail, chat interna e condivisione risorse",
|
||||
Icon = "Email",
|
||||
BasePrice = 1000m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 90,
|
||||
IsCore = false,
|
||||
RoutePath = "/communications",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new App
|
||||
{
|
||||
Code = "training",
|
||||
Name = "Formazione",
|
||||
Description = "Gestione formazione obbligatoria, corsi, scadenze e attestati",
|
||||
Icon = "School",
|
||||
BasePrice = 1400m,
|
||||
MonthlyMultiplier = 1.2m,
|
||||
SortOrder = 100,
|
||||
IsCore = false,
|
||||
RoutePath = "/training",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
@@ -24,8 +24,21 @@ public class Articolo : BaseEntity
|
||||
public string? MimeType { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public bool Attivo { get; set; } = true;
|
||||
public int? GiorniValidita { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Classificazione specifica dell'articolo (Standard, Corso, Servizio)
|
||||
/// </summary>
|
||||
public TipoArticolo Tipo { get; set; } = TipoArticolo.Standard;
|
||||
|
||||
public TipoMateriale? TipoMateriale { get; set; }
|
||||
public CodiceCategoria? Categoria { get; set; }
|
||||
public ICollection<EventoDettaglioPrelievo> DettagliPrelievo { get; set; } = new List<EventoDettaglioPrelievo>();
|
||||
}
|
||||
|
||||
public enum TipoArticolo
|
||||
{
|
||||
Standard = 0,
|
||||
Corso = 1,
|
||||
Servizio = 2
|
||||
}
|
||||
|
||||
@@ -28,4 +28,5 @@ public class Cliente : BaseEntity
|
||||
|
||||
public ICollection<Evento> Eventi { get; set; } = new List<Evento>();
|
||||
public ICollection<Zentral.Domain.Entities.Sales.SalesOrder> SalesOrders { get; set; } = new List<Zentral.Domain.Entities.Sales.SalesOrder>();
|
||||
public ICollection<ClienteContatto> Contatti { get; set; } = new List<ClienteContatto>();
|
||||
}
|
||||
|
||||
12
src/backend/Zentral.Domain/Entities/ClienteContatto.cs
Normal file
12
src/backend/Zentral.Domain/Entities/ClienteContatto.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Zentral.Domain.Entities;
|
||||
|
||||
public class ClienteContatto : BaseEntity
|
||||
{
|
||||
public string Nome { get; set; } = string.Empty;
|
||||
public string Cognome { get; set; } = string.Empty;
|
||||
public string? Email { get; set; }
|
||||
public string? Ruolo { get; set; }
|
||||
public string? Telefono { get; set; }
|
||||
public int ClienteId { get; set; }
|
||||
public Cliente Cliente { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using Zentral.Domain;
|
||||
|
||||
namespace Zentral.Domain.Entities.Communications;
|
||||
|
||||
public class EmailLog : BaseEntity
|
||||
{
|
||||
public DateTime SentDate { get; set; }
|
||||
public string Sender { get; set; } = string.Empty;
|
||||
public string Recipient { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty; // "Success", "Failed"
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Zentral.Domain.Entities.Training;
|
||||
|
||||
public enum NotificationStatus
|
||||
{
|
||||
Pending,
|
||||
Approved,
|
||||
Sent,
|
||||
Error
|
||||
}
|
||||
|
||||
public class TrainingNotification : BaseEntity
|
||||
{
|
||||
public int? ClienteId { get; set; } // Notifications are grouped by Client (Company)
|
||||
public Cliente? Cliente { get; set; }
|
||||
|
||||
public string RecipientEmail { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
public DateTime ScheduledDate { get; set; }
|
||||
public DateTime? SentDate { get; set; }
|
||||
|
||||
// JSON array of TrainingRecord IDs included in this notification
|
||||
public string IncludedRecordIds { get; set; } = "[]";
|
||||
|
||||
public NotificationStatus Status { get; set; } = NotificationStatus.Pending;
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
// Optional: Link to specific TrainingRecords if needed for traceability,
|
||||
// but if it's a grouped email, maybe just a JSON list or text description in Body is enough.
|
||||
// Let's keep it simple for now, the Body will contain the details.
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Zentral.Domain.Entities.Training;
|
||||
|
||||
public enum TrainingStatus
|
||||
{
|
||||
Valid,
|
||||
Expiring,
|
||||
Expired
|
||||
}
|
||||
|
||||
public class TrainingRecord : BaseEntity
|
||||
{
|
||||
public int ClienteContattoId { get; set; }
|
||||
public ClienteContatto ClienteContatto { get; set; } = null!;
|
||||
|
||||
public int ArticoloId { get; set; }
|
||||
public Articolo Articolo { get; set; } = null!;
|
||||
|
||||
public DateTime DataEsecuzione { get; set; }
|
||||
public DateTime? DataScadenza { get; set; }
|
||||
|
||||
public string? AttestatoUrl { get; set; }
|
||||
public string? Note { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public TrainingStatus Stato
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!DataScadenza.HasValue) return TrainingStatus.Valid;
|
||||
var days = (DataScadenza.Value - DateTime.Today).TotalDays;
|
||||
if (days < 0) return TrainingStatus.Expired;
|
||||
if (days <= 30) return TrainingStatus.Expiring; // Configurable ideally
|
||||
return TrainingStatus.Valid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,11 @@ public class WarehouseArticle : BaseEntity
|
||||
/// </summary>
|
||||
public int? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gruppo merceologico
|
||||
/// </summary>
|
||||
public int? ProductGroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unità di misura principale (es. PZ, KG, LT, MT)
|
||||
/// </summary>
|
||||
|
||||
7
src/backend/Zentral.Domain/Interfaces/IEmailSender.cs
Normal file
7
src/backend/Zentral.Domain/Interfaces/IEmailSender.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Zentral.Domain.Interfaces;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task SendEmailAsync(string to, string subject, string body, bool isHtml = true);
|
||||
Task SendEmailAsync(string to, string subject, string body, List<string> attachments, bool isHtml = true);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ public static class DbSeeder
|
||||
{
|
||||
public static void Seed(ZentralDbContext context)
|
||||
{
|
||||
if (context.TipiPasto.Any()) return;
|
||||
if (!context.TipiPasto.Any())
|
||||
{
|
||||
|
||||
// Tipi Pasto
|
||||
var tipiPasto = new List<TipoPasto>
|
||||
@@ -72,7 +73,8 @@ public static class DbSeeder
|
||||
new() { Id = 1, Codice = "A", Descrizione = "Per Adulti", CoeffA = 1.0m, CoeffB = 0.5m, CoeffS = 1.0m },
|
||||
new() { Id = 2, Codice = "B", Descrizione = "Per Buffet", CoeffA = 0.8m, CoeffB = 1.0m, CoeffS = 0.8m },
|
||||
new() { Id = 3, Codice = "S", Descrizione = "Per Seduti", CoeffA = 1.0m, CoeffB = 0.6m, CoeffS = 1.0m },
|
||||
new() { Id = 4, Codice = "U", Descrizione = "Universale", CoeffA = 1.0m, CoeffB = 1.0m, CoeffS = 1.0m }
|
||||
new() { Id = 4, Codice = "U", Descrizione = "Universale", CoeffA = 1.0m, CoeffB = 1.0m, CoeffS = 1.0m },
|
||||
new() { Id = 5, Codice = "TRAIN", Descrizione = "Formazione", CoeffA = 1.0m, CoeffB = 1.0m, CoeffS = 1.0m }
|
||||
};
|
||||
context.CodiciCategoria.AddRange(categorie);
|
||||
|
||||
@@ -230,7 +232,78 @@ public static class DbSeeder
|
||||
new() { Id = 4, Username = "operatore", Nome = "Operatore", Ruolo = "Operatore" }
|
||||
};
|
||||
context.Utenti.AddRange(utenti);
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
// Ensure TRAIN category exists
|
||||
if (!context.CodiciCategoria.Any(c => c.Codice == "TRAIN"))
|
||||
{
|
||||
context.CodiciCategoria.Add(new CodiceCategoria
|
||||
{
|
||||
Codice = "TRAIN",
|
||||
Descrizione = "Formazione",
|
||||
CoeffA = 1.0m,
|
||||
CoeffB = 1.0m,
|
||||
CoeffS = 1.0m
|
||||
});
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
// Apps
|
||||
if (!context.Apps.Any())
|
||||
{
|
||||
var apps = new List<App>
|
||||
{
|
||||
new() { Code = "warehouse", Name = "Magazzino", Icon = "Warehouse", BasePrice = 100, RoutePath = "/warehouse", SortOrder = 10, Description = "Gestione completa del magazzino" },
|
||||
new() { Code = "purchases", Name = "Acquisti", Icon = "ShoppingCart", BasePrice = 80, RoutePath = "/purchases", SortOrder = 20, Description = "Gestione ciclo passivo e fornitori" },
|
||||
new() { Code = "sales", Name = "Vendite", Icon = "PointOfSale", BasePrice = 80, RoutePath = "/sales", SortOrder = 30, Description = "Gestione ciclo attivo e clienti" },
|
||||
new() { Code = "production", Name = "Produzione", Icon = "Factory", BasePrice = 150, RoutePath = "/production", SortOrder = 40, Description = "Gestione della produzione e MRP" },
|
||||
new() { Code = "events", Name = "Eventi", Icon = "Event", BasePrice = 120, RoutePath = "/events", SortOrder = 50, Description = "Gestione eventi e catering" },
|
||||
new() { Code = "hr", Name = "Personale", Icon = "People", BasePrice = 60, RoutePath = "/hr", SortOrder = 60, Description = "Gestione risorse umane" },
|
||||
new() { Code = "communications", Name = "Comunicazioni", Icon = "Email", BasePrice = 40, RoutePath = "/communications", SortOrder = 70, Description = "Gestione email e comunicazioni" },
|
||||
new() { Code = "report-designer", Name = "Report Designer", Icon = "Print", BasePrice = 50, RoutePath = "/report-designer", SortOrder = 80, Description = "Editor di report personalizzati" },
|
||||
new() { Code = "training", Name = "Formazione", Icon = "School", BasePrice = 50, RoutePath = "/training", SortOrder = 90, Description = "Gestione corsi e scadenze formazione" }
|
||||
};
|
||||
context.Apps.AddRange(apps);
|
||||
context.SaveChanges();
|
||||
|
||||
// Auto-subscribe all for demo/dev
|
||||
foreach (var app in apps)
|
||||
{
|
||||
context.AppSubscriptions.Add(new AppSubscription
|
||||
{
|
||||
AppId = app.Id,
|
||||
IsEnabled = true,
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddYears(1),
|
||||
SubscriptionType = SubscriptionType.Annual,
|
||||
AutoRenew = true,
|
||||
PaidPrice = app.BasePrice
|
||||
});
|
||||
}
|
||||
context.SaveChanges();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ensure Training exists if apps already seeded
|
||||
if (!context.Apps.Any(a => a.Code == "training"))
|
||||
{
|
||||
var trainingApp = new App { Code = "training", Name = "Formazione", Icon = "School", BasePrice = 50, RoutePath = "/training", SortOrder = 90, Description = "Gestione corsi e scadenze formazione" };
|
||||
context.Apps.Add(trainingApp);
|
||||
context.SaveChanges();
|
||||
|
||||
context.AppSubscriptions.Add(new AppSubscription
|
||||
{
|
||||
AppId = trainingApp.Id,
|
||||
IsEnabled = true,
|
||||
StartDate = DateTime.UtcNow,
|
||||
EndDate = DateTime.UtcNow.AddYears(1),
|
||||
SubscriptionType = SubscriptionType.Annual,
|
||||
AutoRenew = true,
|
||||
PaidPrice = trainingApp.BasePrice
|
||||
});
|
||||
context.SaveChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using Zentral.Domain.Entities.Purchases;
|
||||
using Zentral.Domain.Entities.Sales;
|
||||
using Zentral.Domain.Entities.Production;
|
||||
using Zentral.Domain.Entities.HR;
|
||||
using Zentral.Domain.Entities.Communications;
|
||||
using Zentral.Domain.Entities.Training;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Zentral.Infrastructure.Data;
|
||||
@@ -94,6 +96,14 @@ public class ZentralDbContext : DbContext
|
||||
public DbSet<Assenza> Assenze => Set<Assenza>();
|
||||
public DbSet<Pagamento> Pagamenti => Set<Pagamento>();
|
||||
public DbSet<Rimborso> Rimborsi => Set<Rimborso>();
|
||||
|
||||
// Communications module entities
|
||||
public DbSet<EmailLog> EmailLogs => Set<EmailLog>();
|
||||
|
||||
// Training module entities
|
||||
public DbSet<ClienteContatto> Contatti => Set<ClienteContatto>();
|
||||
public DbSet<TrainingRecord> TrainingRecords => Set<TrainingRecord>();
|
||||
public DbSet<TrainingNotification> TrainingNotifications => Set<TrainingNotification>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -389,6 +399,35 @@ public class ZentralDbContext : DbContext
|
||||
entity.HasIndex(e => e.EntityName);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ClienteContatto
|
||||
modelBuilder.Entity<ClienteContatto>(entity =>
|
||||
{
|
||||
entity.ToTable("ClienteContatti");
|
||||
|
||||
entity.HasOne(e => e.Cliente)
|
||||
.WithMany(c => c.Contatti)
|
||||
.HasForeignKey(e => e.ClienteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// TrainingRecord
|
||||
modelBuilder.Entity<TrainingRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("TrainingRecords");
|
||||
|
||||
entity.HasOne(e => e.ClienteContatto)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ClienteContattoId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Articolo)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ArticoloId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ===============================================
|
||||
// WAREHOUSE MODULE ENTITIES
|
||||
// ===============================================
|
||||
@@ -441,6 +480,7 @@ public class ZentralDbContext : DbContext
|
||||
.WithMany(c => c.Articles)
|
||||
.HasForeignKey(e => e.CategoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
});
|
||||
|
||||
// ArticleBatch
|
||||
@@ -989,5 +1029,16 @@ public class ZentralDbContext : DbContext
|
||||
.HasForeignKey(e => e.ArticleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ===============================================
|
||||
// COMMUNICATIONS MODULE ENTITIES
|
||||
// ===============================================
|
||||
modelBuilder.Entity<EmailLog>(entity =>
|
||||
{
|
||||
entity.ToTable("EmailLogs");
|
||||
entity.HasIndex(e => e.SentDate);
|
||||
entity.HasIndex(e => e.Status);
|
||||
entity.HasIndex(e => e.Recipient);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
4704
src/backend/Zentral.Infrastructure/Migrations/20251206011423_UpdateAutoCodeModules.Designer.cs
generated
Normal file
4704
src/backend/Zentral.Infrastructure/Migrations/20251206011423_UpdateAutoCodeModules.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateAutoCodeModules : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE AutoCodes SET ModuleCode = 'warehouse' WHERE EntityCode IN ('warehouse_article', 'warehouse_location', 'inventory_count', 'stock_movement', 'stock_valuation')");
|
||||
migrationBuilder.Sql("UPDATE AutoCodes SET ModuleCode = 'purchases' WHERE EntityCode IN ('supplier', 'purchase_order')");
|
||||
migrationBuilder.Sql("UPDATE AutoCodes SET ModuleCode = 'sales' WHERE EntityCode IN ('sales_order')");
|
||||
migrationBuilder.Sql("UPDATE AutoCodes SET ModuleCode = 'production' WHERE EntityCode IN ('production_order', 'bill_of_materials', 'work_center', 'production_cycle')");
|
||||
migrationBuilder.Sql("UPDATE AutoCodes SET ModuleCode = 'core' WHERE EntityCode IN ('cliente')");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
4758
src/backend/Zentral.Infrastructure/Migrations/20251212105451_UpdateCommunicationsModule.Designer.cs
generated
Normal file
4758
src/backend/Zentral.Infrastructure/Migrations/20251212105451_UpdateCommunicationsModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateCommunicationsModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmailLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SentDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Sender = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Recipient = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Subject = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmailLogs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailLogs_Recipient",
|
||||
table: "EmailLogs",
|
||||
column: "Recipient");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailLogs_SentDate",
|
||||
table: "EmailLogs",
|
||||
column: "SentDate");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailLogs_Status",
|
||||
table: "EmailLogs",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmailLogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
4823
src/backend/Zentral.Infrastructure/Migrations/20251212115332_AddWarehouseProductGroups.Designer.cs
generated
Normal file
4823
src/backend/Zentral.Infrastructure/Migrations/20251212115332_AddWarehouseProductGroups.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWarehouseProductGroups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WarehouseProductGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WarehouseProductGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseArticles_ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
column: "ProductGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseProductGroups_Code",
|
||||
table: "WarehouseProductGroups",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseProductGroups_IsActive",
|
||||
table: "WarehouseProductGroups",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
column: "ProductGroupId",
|
||||
principalTable: "WarehouseProductGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WarehouseProductGroups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_WarehouseArticles_ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
}
|
||||
}
|
||||
}
|
||||
4761
src/backend/Zentral.Infrastructure/Migrations/20251212122107_RemoveWarehouseProductGroups.Designer.cs
generated
Normal file
4761
src/backend/Zentral.Infrastructure/Migrations/20251212122107_RemoveWarehouseProductGroups.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveWarehouseProductGroups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WarehouseProductGroups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_WarehouseArticles_ProductGroupId",
|
||||
table: "WarehouseArticles");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WarehouseProductGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WarehouseProductGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseArticles_ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
column: "ProductGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseProductGroups_Code",
|
||||
table: "WarehouseProductGroups",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WarehouseProductGroups_IsActive",
|
||||
table: "WarehouseProductGroups",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
|
||||
table: "WarehouseArticles",
|
||||
column: "ProductGroupId",
|
||||
principalTable: "WarehouseProductGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
}
|
||||
}
|
||||
4892
src/backend/Zentral.Infrastructure/Migrations/20251212143625_AddTrainingModule.Designer.cs
generated
Normal file
4892
src/backend/Zentral.Infrastructure/Migrations/20251212143625_AddTrainingModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrainingModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GiorniValidita",
|
||||
table: "Articoli",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClienteContatti",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Nome = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Cognome = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Ruolo = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Telefono = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ClienteId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ClienteContatti", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClienteContatti_Clienti_ClienteId",
|
||||
column: x => x.ClienteId,
|
||||
principalTable: "Clienti",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TrainingRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ClienteContattoId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ArticoloId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DataEsecuzione = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
DataScadenza = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
AttestatoUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Note = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TrainingRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrainingRecords_Articoli_ArticoloId",
|
||||
column: x => x.ArticoloId,
|
||||
principalTable: "Articoli",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrainingRecords_ClienteContatti_ClienteContattoId",
|
||||
column: x => x.ClienteContattoId,
|
||||
principalTable: "ClienteContatti",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClienteContatti_ClienteId",
|
||||
table: "ClienteContatti",
|
||||
column: "ClienteId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrainingRecords_ArticoloId",
|
||||
table: "TrainingRecords",
|
||||
column: "ArticoloId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrainingRecords_ClienteContattoId",
|
||||
table: "TrainingRecords",
|
||||
column: "ClienteContattoId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TrainingRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClienteContatti");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GiorniValidita",
|
||||
table: "Articoli");
|
||||
}
|
||||
}
|
||||
}
|
||||
4895
src/backend/Zentral.Infrastructure/Migrations/20251212170220_AddTipoArticolo.Designer.cs
generated
Normal file
4895
src/backend/Zentral.Infrastructure/Migrations/20251212170220_AddTipoArticolo.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTipoArticolo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Tipo",
|
||||
table: "Articoli",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Tipo",
|
||||
table: "Articoli");
|
||||
}
|
||||
}
|
||||
}
|
||||
4963
src/backend/Zentral.Infrastructure/Migrations/20251213155224_AddTrainingNotifications.Designer.cs
generated
Normal file
4963
src/backend/Zentral.Infrastructure/Migrations/20251213155224_AddTrainingNotifications.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Zentral.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrainingNotifications : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TrainingNotifications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ClienteId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
RecipientEmail = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Subject = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Body = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ScheduledDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
SentDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
IncludedRecordIds = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TrainingNotifications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrainingNotifications_Clienti_ClienteId",
|
||||
column: x => x.ClienteId,
|
||||
principalTable: "Clienti",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrainingNotifications_ClienteId",
|
||||
table: "TrainingNotifications",
|
||||
column: "ClienteId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TrainingNotifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,9 @@ namespace Zentral.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("GiorniValidita")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte[]>("Immagine")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
@@ -195,6 +198,9 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.Property<decimal?>("QtaStdS")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Tipo")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TipoMaterialeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -372,6 +378,54 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.ToTable("Clienti");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.ClienteContatto", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ClienteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Cognome")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomFieldsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nome")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Ruolo")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Telefono")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClienteId");
|
||||
|
||||
b.ToTable("ClienteContatti", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.CodiceCategoria", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -418,6 +472,60 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.ToTable("CodiciCategoria");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Communications.EmailLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomFieldsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Recipient")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Sender")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("SentDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Recipient");
|
||||
|
||||
b.HasIndex("SentDate");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("EmailLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Configurazione", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2541,6 +2649,113 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.ToTable("TipiRisorsa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingNotification", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ClienteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomFieldsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("IncludedRecordIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RecipientEmail")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ScheduledDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("SentDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClienteId");
|
||||
|
||||
b.ToTable("TrainingNotifications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingRecord", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ArticoloId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AttestatoUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ClienteContattoId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CustomFieldsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DataEsecuzione")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DataScadenza")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ArticoloId");
|
||||
|
||||
b.HasIndex("ClienteContattoId");
|
||||
|
||||
b.ToTable("TrainingRecords", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.UserDashboardPreference", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3621,6 +3836,9 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ProductGroupId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("ReorderPoint")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -3867,6 +4085,17 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.Navigation("TipoMateriale");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.ClienteContatto", b =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Cliente", "Cliente")
|
||||
.WithMany("Contatti")
|
||||
.HasForeignKey("ClienteId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Cliente");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Evento", b =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Cliente", "Cliente")
|
||||
@@ -4249,6 +4478,34 @@ namespace Zentral.Infrastructure.Migrations
|
||||
b.Navigation("TipoPasto");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingNotification", b =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Cliente", "Cliente")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClienteId");
|
||||
|
||||
b.Navigation("Cliente");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Training.TrainingRecord", b =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Articolo", "Articolo")
|
||||
.WithMany()
|
||||
.HasForeignKey("ArticoloId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Zentral.Domain.Entities.ClienteContatto", "ClienteContatto")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClienteContattoId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Articolo");
|
||||
|
||||
b.Navigation("ClienteContatto");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.UserDashboardPreference", b =>
|
||||
{
|
||||
b.HasOne("Zentral.Domain.Entities.Utente", "Utente")
|
||||
@@ -4531,6 +4788,8 @@ namespace Zentral.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("Zentral.Domain.Entities.Cliente", b =>
|
||||
{
|
||||
b.Navigation("Contatti");
|
||||
|
||||
b.Navigation("Eventi");
|
||||
|
||||
b.Navigation("SalesOrders");
|
||||
|
||||
199
src/backend/Zentral.Infrastructure/Services/SmtpEmailSender.cs
Normal file
199
src/backend/Zentral.Infrastructure/Services/SmtpEmailSender.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MimeKit;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Net.Http;
|
||||
using Zentral.Domain.Entities.Communications;
|
||||
using Zentral.Domain.Interfaces;
|
||||
using Zentral.Infrastructure.Data;
|
||||
|
||||
namespace Zentral.Infrastructure.Services;
|
||||
|
||||
public class SmtpEmailSender : IEmailSender
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public SmtpEmailSender(IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true)
|
||||
{
|
||||
await SendEmailAsync(to, subject, body, new List<string>(), isHtml);
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string to, string subject, string body, List<string> attachments, bool isHtml = true)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<ZentralDbContext>();
|
||||
|
||||
// 1. Get Configuration
|
||||
var configs = await context.Configurazioni
|
||||
.Where(c => c.Chiave.StartsWith("SMTP_") || c.Chiave == "EMAIL_PROVIDER" || c.Chiave == "RESEND_API_KEY")
|
||||
.ToDictionaryAsync(c => c.Chiave, c => c.Valore);
|
||||
|
||||
var provider = GetConfig(configs, "EMAIL_PROVIDER", "smtp");
|
||||
|
||||
if (provider.ToLower() == "resend")
|
||||
{
|
||||
await SendViaResendAsync(context, to, subject, body, attachments, isHtml, configs);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendViaSmtpAsync(context, to, subject, body, attachments, isHtml, configs);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendViaResendAsync(ZentralDbContext context, string to, string subject, string body, List<string> attachments, bool isHtml, Dictionary<string, string?> configs)
|
||||
{
|
||||
var apiKey = GetConfig(configs, "RESEND_API_KEY");
|
||||
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL"); // Resend often requires a verified domain, but we reuse the field
|
||||
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", "Resend API Key not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
var request = new
|
||||
{
|
||||
from = $"{fromName} <{fromEmail}>",
|
||||
to = new[] { to },
|
||||
subject = subject,
|
||||
html = isHtml ? body : null,
|
||||
text = !isHtml ? body : null,
|
||||
attachments = attachments.Select(a => {
|
||||
var bytes = System.IO.File.ReadAllBytes(a);
|
||||
return new
|
||||
{
|
||||
filename = System.IO.Path.GetFileName(a),
|
||||
content = Convert.ToBase64String(bytes)
|
||||
};
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("https://api.resend.com/emails", request);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Success", "Via Resend");
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", $"Resend Error: {errorContent}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendViaSmtpAsync(ZentralDbContext context, string to, string subject, string body, List<string> attachments, bool isHtml, Dictionary<string, string?> configs)
|
||||
{
|
||||
var host = GetConfig(configs, "SMTP_HOST");
|
||||
var portStr = GetConfig(configs, "SMTP_PORT", "587");
|
||||
var user = GetConfig(configs, "SMTP_USER");
|
||||
var pass = GetConfig(configs, "SMTP_PASS");
|
||||
var sslStr = GetConfig(configs, "SMTP_SSL", "false");
|
||||
var fromEmail = GetConfig(configs, "SMTP_FROM_EMAIL", user);
|
||||
var fromName = GetConfig(configs, "SMTP_FROM_NAME", "Zentral");
|
||||
|
||||
if (string.IsNullOrEmpty(host))
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", "SMTP Host not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
int.TryParse(portStr, out int port);
|
||||
bool.TryParse(sslStr, out bool useSsl);
|
||||
|
||||
// 2. Prepare Message
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(fromName, fromEmail));
|
||||
message.To.Add(MailboxAddress.Parse(to));
|
||||
message.Subject = subject;
|
||||
|
||||
var builder = new BodyBuilder();
|
||||
if (isHtml)
|
||||
builder.HtmlBody = body;
|
||||
else
|
||||
builder.TextBody = body;
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (System.IO.File.Exists(attachment))
|
||||
{
|
||||
builder.Attachments.Add(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
message.Body = builder.ToMessageBody();
|
||||
|
||||
// 3. Send
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
if (port == 465)
|
||||
await client.ConnectAsync(host, port, SecureSocketOptions.SslOnConnect);
|
||||
else if (port == 587)
|
||||
await client.ConnectAsync(host, port, SecureSocketOptions.StartTls);
|
||||
else
|
||||
await client.ConnectAsync(host, port, SecureSocketOptions.Auto);
|
||||
|
||||
if (!string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(pass))
|
||||
{
|
||||
await client.AuthenticateAsync(user, pass);
|
||||
}
|
||||
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Success", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await LogResultAsync(context, fromEmail, to, subject, "Failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetConfig(Dictionary<string, string?> configs, string key, string defaultValue = "")
|
||||
{
|
||||
return configs.ContainsKey(key) && !string.IsNullOrEmpty(configs[key]) ? configs[key]! : defaultValue;
|
||||
}
|
||||
|
||||
private async Task LogResultAsync(ZentralDbContext context, string from, string to, string subject, string status, string? error)
|
||||
{
|
||||
var log = new EmailLog
|
||||
{
|
||||
SentDate = DateTime.UtcNow,
|
||||
Sender = from,
|
||||
Recipient = to,
|
||||
Subject = subject,
|
||||
Status = status,
|
||||
ErrorMessage = error,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = "System"
|
||||
};
|
||||
|
||||
context.EmailLogs.Add(log);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
|
||||
<PackageReference Include="MailKit" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
62
src/frontend/package-lock.json
generated
62
src/frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fullcalendar/daygrid": "^6.1.19",
|
||||
@@ -335,6 +338,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
@@ -5587,6 +5643,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fullcalendar/daygrid": "^6.1.19",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"preview": "Preview",
|
||||
"none": "None",
|
||||
"view": "View",
|
||||
"copy": "Copy",
|
||||
"required": "Required",
|
||||
"add": "Add",
|
||||
"active": "Active",
|
||||
@@ -51,7 +52,34 @@
|
||||
"reports": "Reports",
|
||||
"apps": "Apps",
|
||||
"autoCodes": "Auto Codes",
|
||||
"customFields": "Custom Fields"
|
||||
"customFields": "Custom Fields",
|
||||
"suppliers": "Suppliers",
|
||||
"purchaseOrders": "Purchase Orders",
|
||||
"salesOrders": "Sales Orders",
|
||||
"productionOrders": "Production Orders",
|
||||
"bom": "Bill of Materials",
|
||||
"workCenters": "Work Centers",
|
||||
"cycles": "Cycles",
|
||||
"mrp": "MRP",
|
||||
"administration": "Administration",
|
||||
"emailConfig": "Email Configuration",
|
||||
"movements": "Movements",
|
||||
"stock": "Stock",
|
||||
"inventory": "Inventory",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"navigation": {
|
||||
"searchPlaceholder": "Search...",
|
||||
"tabGroups": "Tab Groups",
|
||||
"close": "Close",
|
||||
"closeOthers": "Close Others",
|
||||
"closeRight": "Close to the Right",
|
||||
"saveSession": "Save Current Session",
|
||||
"noSavedGroups": "No saved groups",
|
||||
"saveGroupTitle": "Save Tab Group",
|
||||
"groupName": "Group Name",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -258,12 +286,33 @@
|
||||
"confermato": "Confirmed"
|
||||
},
|
||||
"apps": {
|
||||
"core": {
|
||||
"title": "Zentral"
|
||||
},
|
||||
"warehouse": {
|
||||
"title": "Warehouse Management",
|
||||
"inventory": "Inventory",
|
||||
"movements": "Movements",
|
||||
"stock": "Stock",
|
||||
"categories": "Categories"
|
||||
"categories": {
|
||||
"title": "Article Categories",
|
||||
"new": "New Category",
|
||||
"edit": "Edit Category",
|
||||
"empty": "No categories found",
|
||||
"newParams": {
|
||||
"root": "New Root Category"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"sortOrder": "Sort Order",
|
||||
"active": "Active"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Confirmation",
|
||||
"content": "Are you sure you want to delete this category? This operation cannot be undone. If the category contains subcategories or articles, it may not be possible to delete it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"hr": {
|
||||
"title": "Human Resources",
|
||||
@@ -273,6 +322,16 @@
|
||||
"pagamenti": "Payments",
|
||||
"rimborsi": "Reimbursements"
|
||||
},
|
||||
"training": {
|
||||
"title": "Training Management",
|
||||
"dashboard": "Dashboard",
|
||||
"matrix": "Matrix",
|
||||
"registry": "Course Registry",
|
||||
"workers": "Workers",
|
||||
"deadlines": "Deadlines",
|
||||
"notifications": "Notifications",
|
||||
"dataExchange": "Import/Export"
|
||||
},
|
||||
"admin": {
|
||||
"title": "App Management",
|
||||
"subtitle": "Configure active apps and manage subscriptions",
|
||||
@@ -373,6 +432,14 @@
|
||||
"4": "Expense reports and reimbursements",
|
||||
"5": "Personnel cost analysis"
|
||||
},
|
||||
"training": {
|
||||
"0": "Course registry management",
|
||||
"1": "Participant registry",
|
||||
"2": "Expiry and renewal monitoring",
|
||||
"3": "Certificate archiving",
|
||||
"4": "Competence matrix",
|
||||
"5": "Automatic expiry notifications"
|
||||
},
|
||||
"default": "Complete app features"
|
||||
}
|
||||
},
|
||||
@@ -406,10 +473,20 @@
|
||||
"patternHelper": "Pattern for code generation",
|
||||
"previewLabel": "Preview:",
|
||||
"resetSequence": "Reset Sequence",
|
||||
"description": "Description",
|
||||
"everyYear": "Every year",
|
||||
"everyMonth": "Every month",
|
||||
"generationActive": "Generation active",
|
||||
"readOnly": "Code not editable"
|
||||
"readOnly": "Code not editable",
|
||||
"noConfigs": "No automatic codes configured for this app.",
|
||||
"modules": {
|
||||
"core": "Core System",
|
||||
"warehouse": "Warehouse",
|
||||
"purchases": "Purchases",
|
||||
"sales": "Sales",
|
||||
"production": "Production",
|
||||
"quality": "Quality"
|
||||
}
|
||||
},
|
||||
"customFields": {
|
||||
"title": "Custom Fields Management",
|
||||
@@ -515,6 +592,135 @@
|
||||
"saving": "Saving...",
|
||||
"save": "Save"
|
||||
}
|
||||
},
|
||||
"elements": {
|
||||
"text": "Text",
|
||||
"textDesc": "Add a text field",
|
||||
"image": "Image",
|
||||
"imageDesc": "Insert an image",
|
||||
"shape": "Shape",
|
||||
"shapeDesc": "Draw a geometric shape",
|
||||
"table": "Table",
|
||||
"tableDesc": "Insert a data table",
|
||||
"line": "Line",
|
||||
"lineDesc": "Draw a line",
|
||||
"add": "Add",
|
||||
"insert": "Insert element"
|
||||
},
|
||||
"snap": {
|
||||
"grid": "Grid",
|
||||
"objects": "Objects",
|
||||
"borders": "Margins",
|
||||
"center": "Center",
|
||||
"tangent": "Edges",
|
||||
"options": "Snap Options",
|
||||
"all": "All",
|
||||
"hideGrid": "Hide grid",
|
||||
"showGrid": "Show grid",
|
||||
"autoAlign": "Auto alignment"
|
||||
},
|
||||
"toolbar": {
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"delete": "Delete",
|
||||
"preview": "Preview",
|
||||
"save": "Save",
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
"duplicate": "Duplicate",
|
||||
"prevPage": "Previous Page",
|
||||
"nextPage": "Next Page",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"autoSaveOn": "Auto-save on",
|
||||
"autoSaveOff": "Auto-save off",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"unsaved": "Unsaved",
|
||||
"unsavedTooltip": "Unsaved changes",
|
||||
"autoSavePending": "Auto-save pending...",
|
||||
"edit": "EDIT",
|
||||
"history": "HISTORY",
|
||||
"historyTooltip": "Change history",
|
||||
"view": "VIEW",
|
||||
"zoom": "ZOOM",
|
||||
"fitWindow": "Fit to window",
|
||||
"zoomLevel": "Zoom level",
|
||||
"presets": "Presets",
|
||||
"searchCommand": "Search command",
|
||||
"shortcuts": "Keyboard shortcuts",
|
||||
"shortcutsTitle": "Keyboard Shortcuts"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Report Preview",
|
||||
"notSelected": "Not selected",
|
||||
"removeSelection": "Remove selection",
|
||||
"select": "Select",
|
||||
"searchPlaceholder": "Search...",
|
||||
"noResults": "No results found",
|
||||
"noEntities": "No entities available",
|
||||
"results": "results",
|
||||
"selected": "selected",
|
||||
"instruction": "Select an entity for each dataset to use in the preview",
|
||||
"errorLoading": "Error loading available data",
|
||||
"noDatasets": "There are no datasets selected for this template. Add at least one dataset to generate the preview.",
|
||||
"selectEntityInstruction": "Select an entity for each dataset",
|
||||
"cancel": "Cancel",
|
||||
"generating": "Generating...",
|
||||
"generatePdf": "Generate PDF",
|
||||
"generatePreviewPdf": "Generate PDF Preview"
|
||||
},
|
||||
"datasetManager": {
|
||||
"title": "Virtual Datasets",
|
||||
"newDataset": "New Dataset",
|
||||
"noDatasets": "No Virtual Datasets",
|
||||
"noDatasetsDesc": "Create virtual datasets to combine and filter data from multiple sources.",
|
||||
"createFirst": "Create the first dataset",
|
||||
"editDataset": "Edit Dataset",
|
||||
"newVirtualDataset": "New Virtual Dataset",
|
||||
"deleteConfirm": "Delete dataset \"{{name}}\"?",
|
||||
"validationError": "Validation error",
|
||||
"errors": "Errors:",
|
||||
"warnings": "Warnings:",
|
||||
"validConfig": "Valid configuration",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"sources": "Sources",
|
||||
"relationships": "Relationships",
|
||||
"filters": "Filters",
|
||||
"fields": "Fields"
|
||||
},
|
||||
"fields": {
|
||||
"nameId": "Identifier Name",
|
||||
"nameIdHelper": "Unique name used internally (no spaces)",
|
||||
"displayName": "Display Name",
|
||||
"description": "Description",
|
||||
"category": "Category",
|
||||
"icon": "Icon"
|
||||
},
|
||||
"sources": {
|
||||
"available": "Available Datasets",
|
||||
"addInstruction": "Click to add a source",
|
||||
"inDataset": "Sources in Dataset",
|
||||
"empty": "Add at least one data source from the left panel",
|
||||
"alias": "Alias",
|
||||
"primary": "Primary",
|
||||
"setPrimary": "Set Primary"
|
||||
},
|
||||
"noDescription": "No description",
|
||||
"sourcesCount": "sources"
|
||||
},
|
||||
"shortcuts": {
|
||||
"move1px": "Move (1px)",
|
||||
"move10px": "Move (10px)",
|
||||
"toggleGrid": "Toggle grid",
|
||||
"zoomInOut": "Zoom in/out",
|
||||
"changePage": "Change page"
|
||||
},
|
||||
"time": {
|
||||
"now": "Just now",
|
||||
"minutesAgo": "{{count}}m ago",
|
||||
"hoursAgo": "{{count}}h ago"
|
||||
}
|
||||
},
|
||||
"warehouse": {
|
||||
@@ -1072,17 +1278,19 @@
|
||||
}
|
||||
},
|
||||
"purchases": {
|
||||
"menu": {
|
||||
"suppliers": "Suppliers",
|
||||
"orders": "Purchase Orders"
|
||||
"stats": {
|
||||
"title": "Purchases",
|
||||
"costsThisMonth": "Costs this month",
|
||||
"pendingOrders": "{{count}} Pending Orders"
|
||||
},
|
||||
"suppliers": {
|
||||
"supplier": {
|
||||
"title": "Suppliers",
|
||||
"newSupplier": "New Supplier",
|
||||
"editSupplier": "Edit Supplier",
|
||||
"createTitle": "New Supplier",
|
||||
"editTitle": "Edit Supplier",
|
||||
"columns": {
|
||||
"code": "Code",
|
||||
"name": "Name",
|
||||
"name": "Business Name",
|
||||
"vatNumber": "VAT Number",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
@@ -1090,85 +1298,60 @@
|
||||
"status": "Status"
|
||||
},
|
||||
"fields": {
|
||||
"code": "Code",
|
||||
"name": "Business Name",
|
||||
"vatNumber": "VAT Number",
|
||||
"fiscalCode": "Fiscal Code",
|
||||
"email": "Email",
|
||||
"pec": "PEC",
|
||||
"phone": "Phone",
|
||||
"website": "Website",
|
||||
"address": "Address",
|
||||
"city": "City",
|
||||
"province": "Province",
|
||||
"zipCode": "ZIP Code",
|
||||
"country": "Country",
|
||||
"email": "Email",
|
||||
"pec": "PEC",
|
||||
"phone": "Phone",
|
||||
"website": "Website",
|
||||
"paymentTerms": "Payment Terms",
|
||||
"notes": "Notes",
|
||||
"isActive": "Active"
|
||||
},
|
||||
"placeholders": {
|
||||
"search": "Search supplier...",
|
||||
"generatedAutomatically": "Generated automatically"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete this supplier?"
|
||||
"notes": "Notes"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"order": {
|
||||
"title": "Purchase Orders",
|
||||
"newOrder": "New Order",
|
||||
"editOrder": "Edit Order",
|
||||
"columns": {
|
||||
"orderNumber": "Order Number",
|
||||
"orderDate": "Date",
|
||||
"supplier": "Supplier",
|
||||
"status": "Status",
|
||||
"total": "Total",
|
||||
"deliveryDate": "Delivery Date"
|
||||
},
|
||||
"fields": {
|
||||
"orderNumber": "Order Number",
|
||||
"orderDate": "Order Date",
|
||||
"expectedDeliveryDate": "Expected Delivery",
|
||||
"supplier": "Supplier",
|
||||
"destinationWarehouse": "Destination Warehouse",
|
||||
"notes": "Notes",
|
||||
"article": "Article",
|
||||
"quantity": "Quantity",
|
||||
"unitPrice": "Unit Price",
|
||||
"discount": "Discount %",
|
||||
"taxRate": "Tax Rate %",
|
||||
"lineTotal": "Total"
|
||||
},
|
||||
"createTitle": "New Order",
|
||||
"editTitle": "Edit Order",
|
||||
"status": {
|
||||
"Draft": "Draft",
|
||||
"Confirmed": "Confirmed",
|
||||
"PartiallyReceived": "Partially Received",
|
||||
"Received": "Received",
|
||||
"Cancelled": "Cancelled"
|
||||
},
|
||||
"columns": {
|
||||
"number": "Number",
|
||||
"date": "Date",
|
||||
"supplier": "Supplier",
|
||||
"status": "Status",
|
||||
"total": "Total"
|
||||
},
|
||||
"fields": {
|
||||
"date": "Order Date",
|
||||
"expectedDate": "Expected Delivery Date",
|
||||
"supplier": "Supplier",
|
||||
"warehouse": "Destination Warehouse",
|
||||
"notes": "Notes"
|
||||
},
|
||||
"lines": {
|
||||
"article": "Article",
|
||||
"quantity": "Quantity",
|
||||
"price": "Unit Price",
|
||||
"discount": "Discount %",
|
||||
"tax": "Tax %",
|
||||
"total": "Total"
|
||||
},
|
||||
"total": "Order Total",
|
||||
"actions": {
|
||||
"addLine": "Add Line",
|
||||
"confirm": "Confirm Order",
|
||||
"receive": "Receive Goods",
|
||||
"view": "View",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"totals": {
|
||||
"net": "Net Total",
|
||||
"tax": "Tax",
|
||||
"gross": "Gross Total"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete this order?",
|
||||
"confirmDialog": {
|
||||
"title": "Confirm Order",
|
||||
"content": "Are you sure you want to confirm this order? It will no longer be editable.",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"receiveDialog": {
|
||||
"title": "Receive Goods",
|
||||
"content": "Are you sure you want to mark this order as received? This will generate stock movements.",
|
||||
"confirm": "Receive",
|
||||
"cancel": "Cancel"
|
||||
"receive": "Receive Goods"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1393,6 +1576,72 @@
|
||||
"rimborsiTitle": "Reimbursement Management",
|
||||
"newRimborso": "New Reimbursement",
|
||||
"editRimborso": "Edit Reimbursement",
|
||||
"descrizione": "Description"
|
||||
"descrizione": "Description",
|
||||
"status": {
|
||||
"richiesto": "Requested",
|
||||
"approvato": "Approved",
|
||||
"rimborsato": "Reimbursed",
|
||||
"rifiutato": "Rejected",
|
||||
"richiesta": "Requested",
|
||||
"approvata": "Approved",
|
||||
"rifiutata": "Rejected"
|
||||
},
|
||||
"assenza": {
|
||||
"ferie": "Vacation",
|
||||
"malattia": "Sick Leave",
|
||||
"permesso": "Permit",
|
||||
"altro": "Other"
|
||||
}
|
||||
},
|
||||
"communications": {
|
||||
"settings": {
|
||||
"title": "Email Configuration",
|
||||
"fields": {
|
||||
"provider": "Provider",
|
||||
"host": "SMTP Host",
|
||||
"port": "Port",
|
||||
"user": "Username",
|
||||
"password": "Password",
|
||||
"ssl": "Enable SSL/TLS",
|
||||
"apiKey": "Resend API Key",
|
||||
"fromEmail": "From Email",
|
||||
"fromName": "From Name"
|
||||
},
|
||||
"helpers": {
|
||||
"apiKey": "Get your API Key at"
|
||||
},
|
||||
"sections": {
|
||||
"defaultSender": "Default Sender"
|
||||
},
|
||||
"actions": {
|
||||
"testConnection": "Test Connection",
|
||||
"sendTest": "Send Test"
|
||||
},
|
||||
"testStats": {
|
||||
"title": "Test Email",
|
||||
"recipient": "Recipient",
|
||||
"subject": "Subject"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "Error loading configuration",
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveError": "Error saving configuration",
|
||||
"recipientRequired": "Recipient email is required for test",
|
||||
"testSuccess": "Test email sent successfully",
|
||||
"testError": "Error sending test email"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Email Logs",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"date": "Date",
|
||||
"status": "Status",
|
||||
"sender": "Sender",
|
||||
"recipient": "Recipient",
|
||||
"subject": "Subject",
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,9 @@
|
||||
"notes": "Note",
|
||||
"preview": "Anteprima",
|
||||
"none": "Nessuno",
|
||||
"view": "Dettaglio"
|
||||
"view": "Dettaglio",
|
||||
"copy": "Copia",
|
||||
"category": "Categoria"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -47,7 +49,35 @@
|
||||
"reports": "Report",
|
||||
"apps": "Applicazioni",
|
||||
"autoCodes": "Codici Auto",
|
||||
"customFields": "Campi Personalizzati"
|
||||
"customFields": "Campi Personalizzati",
|
||||
"suppliers": "Fornitori",
|
||||
"purchaseOrders": "Ordini Acquisto",
|
||||
"salesOrders": "Ordini Vendita",
|
||||
"productionOrders": "Ordini Produzione",
|
||||
"bom": "Distinte Base",
|
||||
"workCenters": "Centri di Lavoro",
|
||||
"cycles": "Cicli",
|
||||
"mrp": "MRP",
|
||||
"administration": "Amministrazione",
|
||||
"emailConfig": "Configurazione Email",
|
||||
"movements": "Movimenti",
|
||||
"stock": "Giacenze",
|
||||
"inventory": "Inventario",
|
||||
"categories": "Categorie",
|
||||
"training": "Formazione"
|
||||
},
|
||||
"navigation": {
|
||||
"searchPlaceholder": "Cerca...",
|
||||
"tabGroups": "Gruppi Schede",
|
||||
"close": "Chiudi",
|
||||
"closeOthers": "Chiudi Altre",
|
||||
"closeRight": "Chiudi a Destra",
|
||||
"saveSession": "Salva Sessione Corrente",
|
||||
"noSavedGroups": "Nessun gruppo salvato",
|
||||
"saveGroupTitle": "Salva Gruppo Schede",
|
||||
"groupName": "Nome Gruppo",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -181,6 +211,10 @@
|
||||
"pec": "PEC",
|
||||
"fiscalCode": "Codice Fiscale",
|
||||
"recipientCode": "Codice Destinatario",
|
||||
"contacts": "Contatti",
|
||||
"newContact": "Nuovo Contatto",
|
||||
"editContact": "Modifica Contatto",
|
||||
"role": "Ruolo",
|
||||
"generatedOnSave": "(Generato al salvataggio)",
|
||||
"autoGenerated": "Generato automaticamente",
|
||||
"willBeAssigned": "Verrà assegnato automaticamente",
|
||||
@@ -254,12 +288,33 @@
|
||||
"confermato": "Confermato"
|
||||
},
|
||||
"apps": {
|
||||
"core": {
|
||||
"title": "Zentral"
|
||||
},
|
||||
"warehouse": {
|
||||
"title": "Gestione Magazzino",
|
||||
"inventory": "Inventario",
|
||||
"movements": "Movimenti",
|
||||
"stock": "Giacenze",
|
||||
"categories": "Categorie"
|
||||
"categories": {
|
||||
"title": "Categorie Articoli",
|
||||
"new": "Nuova Categoria",
|
||||
"edit": "Modifica Categoria",
|
||||
"empty": "Nessuna categoria trovata",
|
||||
"newParams": {
|
||||
"root": "Nuova Categoria Root"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"description": "Descrizione",
|
||||
"sortOrder": "Ordinamento",
|
||||
"active": "Attivo"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Conferma Eliminazione",
|
||||
"content": "Sei sicuro di voler eliminare questa categoria? L'operazione non può essere annullata. Se la categoria contiene sottocategorie o articoli, potrebbe non essere possibile eliminarla."
|
||||
}
|
||||
}
|
||||
},
|
||||
"hr": {
|
||||
"title": "Gestione Personale",
|
||||
@@ -269,6 +324,16 @@
|
||||
"pagamenti": "Pagamenti",
|
||||
"rimborsi": "Rimborsi"
|
||||
},
|
||||
"training": {
|
||||
"title": "Gestione Formazione",
|
||||
"dashboard": "Dashboard",
|
||||
"matrix": "Matrice",
|
||||
"registry": "Anagrafica Corsi",
|
||||
"workers": "Lavoratori",
|
||||
"deadlines": "Scadenze",
|
||||
"notifications": "Notifiche",
|
||||
"dataExchange": "Import/Export"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Gestione Applicazioni",
|
||||
"subtitle": "Configura le applicazioni attive e gestisci le subscription",
|
||||
@@ -370,6 +435,14 @@
|
||||
"4": "Note spese e rimborsi",
|
||||
"5": "Analisi costi personale"
|
||||
},
|
||||
"training": {
|
||||
"0": "Gestione anagrafica corsi",
|
||||
"1": "Registro partecipanti",
|
||||
"2": "Monitoraggio scadenze e rinnovi",
|
||||
"3": "Archiviazione attestati",
|
||||
"4": "Matrice competenze",
|
||||
"5": "Notifiche automatiche scadenze"
|
||||
},
|
||||
"default": "Funzionalità complete dell'applicazione"
|
||||
}
|
||||
},
|
||||
@@ -403,10 +476,20 @@
|
||||
"patternHelper": "Pattern per generazione codice",
|
||||
"previewLabel": "Anteprima:",
|
||||
"resetSequence": "Reset Sequenza",
|
||||
"description": "Descrizione",
|
||||
"everyYear": "Ogni anno",
|
||||
"everyMonth": "Ogni mese",
|
||||
"generationActive": "Generazione attiva",
|
||||
"readOnly": "Codice non modificabile"
|
||||
"readOnly": "Codice non modificabile",
|
||||
"noConfigs": "Nessun codice automatico configurato per questa applicazione.",
|
||||
"modules": {
|
||||
"core": "Sistema Base",
|
||||
"warehouse": "Magazzino",
|
||||
"purchases": "Acquisti",
|
||||
"sales": "Vendite",
|
||||
"production": "Produzione",
|
||||
"quality": "Qualità"
|
||||
}
|
||||
},
|
||||
"customFields": {
|
||||
"title": "Gestione Campi Personalizzati",
|
||||
@@ -591,9 +674,143 @@
|
||||
"saving": "Salvataggio...",
|
||||
"save": "Salva"
|
||||
}
|
||||
},
|
||||
"elements": {
|
||||
"text": "Testo",
|
||||
"textDesc": "Aggiungi un campo di testo",
|
||||
"image": "Immagine",
|
||||
"imageDesc": "Inserisci un'immagine",
|
||||
"shape": "Forma",
|
||||
"shapeDesc": "Disegna una forma geometrica",
|
||||
"table": "Tabella",
|
||||
"tableDesc": "Inserisci una tabella dati",
|
||||
"line": "Linea",
|
||||
"lineDesc": "Traccia una linea",
|
||||
"add": "Aggiungi",
|
||||
"insert": "Inserisci elemento"
|
||||
},
|
||||
"snap": {
|
||||
"grid": "Griglia",
|
||||
"objects": "Oggetti",
|
||||
"borders": "Margini",
|
||||
"center": "Centro",
|
||||
"tangent": "Bordi",
|
||||
"options": "Opzioni Snap",
|
||||
"all": "Tutti",
|
||||
"hideGrid": "Nascondi griglia",
|
||||
"showGrid": "Mostra griglia",
|
||||
"autoAlign": "Allineamento automatico"
|
||||
},
|
||||
"toolbar": {
|
||||
"undo": "Annulla",
|
||||
"redo": "Ripeti",
|
||||
"delete": "Elimina",
|
||||
"preview": "Anteprima",
|
||||
"save": "Salva",
|
||||
"lock": "Blocca",
|
||||
"unlock": "Sblocca",
|
||||
"duplicate": "Duplica",
|
||||
"prevPage": "Pagina precedente",
|
||||
"nextPage": "Pagina successiva",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"autoSaveOn": "Auto-salvataggio attivo",
|
||||
"autoSaveOff": "Auto-salvataggio disattivato",
|
||||
"saving": "Salvataggio in corso...",
|
||||
"saved": "Salvato",
|
||||
"unsaved": "Non salvato",
|
||||
"unsavedTooltip": "Modifiche non salvate",
|
||||
"autoSavePending": "Salvataggio automatico in attesa...",
|
||||
"edit": "MODIFICA",
|
||||
"history": "CRONOLOGIA",
|
||||
"historyTooltip": "Cronologia modifiche",
|
||||
"view": "VISTA",
|
||||
"zoom": "ZOOM",
|
||||
"fitWindow": "Adatta alla finestra",
|
||||
"zoomLevel": "Livello zoom",
|
||||
"presets": "Preset",
|
||||
"searchCommand": "Cerca comando",
|
||||
"shortcuts": "Scorciatoie tastiera",
|
||||
"shortcutsTitle": "Scorciatoie Tastiera"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Anteprima Report",
|
||||
"notSelected": "Non selezionato",
|
||||
"removeSelection": "Rimuovi selezione",
|
||||
"select": "Seleziona",
|
||||
"searchPlaceholder": "Cerca...",
|
||||
"noResults": "Nessun risultato trovato",
|
||||
"noEntities": "Nessuna entità disponibile",
|
||||
"results": "risultati",
|
||||
"selected": "selezionati",
|
||||
"instruction": "Seleziona un'entità per ogni dataset da utilizzare nell'anteprima",
|
||||
"errorLoading": "Errore nel caricamento dei dati disponibili",
|
||||
"noDatasets": "Non ci sono dataset selezionati per questo template. Aggiungi almeno un dataset per poter generare l'anteprima.",
|
||||
"selectEntityInstruction": "Seleziona un'entità per ogni dataset",
|
||||
"cancel": "Annulla",
|
||||
"generating": "Generazione...",
|
||||
"generatePdf": "Genera PDF",
|
||||
"generatePreviewPdf": "Genera Anteprima PDF"
|
||||
},
|
||||
"datasetManager": {
|
||||
"title": "Dataset Virtuali",
|
||||
"newDataset": "Nuovo Dataset",
|
||||
"noDatasets": "Nessun Dataset Virtuale",
|
||||
"noDatasetsDesc": "Crea dataset virtuali per combinare e filtrare i dati da più sorgenti.",
|
||||
"createFirst": "Crea il primo dataset",
|
||||
"editDataset": "Modifica Dataset",
|
||||
"newVirtualDataset": "Nuovo Dataset Virtuale",
|
||||
"deleteConfirm": "Eliminare il dataset \"{{name}}\"?",
|
||||
"validationError": "Errore durante la validazione",
|
||||
"errors": "Errori:",
|
||||
"warnings": "Avvisi:",
|
||||
"validConfig": "Configurazione valida",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"sources": "Sorgenti",
|
||||
"relationships": "Relazioni",
|
||||
"filters": "Filtri",
|
||||
"fields": "Campi"
|
||||
},
|
||||
"fields": {
|
||||
"nameId": "Nome Identificativo",
|
||||
"nameIdHelper": "Nome univoco usato internamente (senza spazi)",
|
||||
"displayName": "Nome Visualizzato",
|
||||
"description": "Descrizione",
|
||||
"category": "Categoria",
|
||||
"icon": "Icona"
|
||||
},
|
||||
"sources": {
|
||||
"available": "Dataset Disponibili",
|
||||
"addInstruction": "Clicca per aggiungere una sorgente",
|
||||
"inDataset": "Sorgenti nel Dataset",
|
||||
"empty": "Aggiungi almeno una sorgente dati dal pannello a sinistra",
|
||||
"alias": "Alias",
|
||||
"primary": "Primario",
|
||||
"setPrimary": "Imposta Primario"
|
||||
},
|
||||
"noDescription": "Nessuna descrizione",
|
||||
"sourcesCount": "sorgenti"
|
||||
},
|
||||
"shortcuts": {
|
||||
"move1px": "Sposta (1px)",
|
||||
"move10px": "Sposta (10px)",
|
||||
"toggleGrid": "Mostra/nascondi griglia",
|
||||
"zoomInOut": "Zoom in/out",
|
||||
"changePage": "Cambia pagina"
|
||||
},
|
||||
"time": {
|
||||
"now": "Ora",
|
||||
"minutesAgo": "{{count}}m fa",
|
||||
"hoursAgo": "{{count}}h fa"
|
||||
}
|
||||
},
|
||||
"purchases": {
|
||||
"stats": {
|
||||
"title": "Acquisti",
|
||||
"costsThisMonth": "Costi questo mese",
|
||||
"pendingOrders": "{{count}} Ordini in attesa"
|
||||
},
|
||||
"supplier": {
|
||||
"title": "Fornitori",
|
||||
"newSupplier": "Nuovo Fornitore",
|
||||
@@ -1446,6 +1663,99 @@
|
||||
"rimborsiTitle": "Gestione Rimborsi",
|
||||
"newRimborso": "Nuovo Rimborso",
|
||||
"editRimborso": "Modifica Rimborso",
|
||||
"descrizione": "Descrizione"
|
||||
"descrizione": "Descrizione",
|
||||
"status": {
|
||||
"richiesto": "Richiesto",
|
||||
"approvato": "Approvato",
|
||||
"rimborsato": "Rimborsato",
|
||||
"rifiutato": "Rifiutato",
|
||||
"richiesta": "Richiesta",
|
||||
"approvata": "Approvata",
|
||||
"rifiutata": "Rifiutata"
|
||||
},
|
||||
"assenza": {
|
||||
"ferie": "Ferie",
|
||||
"malattia": "Malattia",
|
||||
"permesso": "Permesso",
|
||||
"altro": "Altro"
|
||||
}
|
||||
},
|
||||
"communications": {
|
||||
"settings": {
|
||||
"title": "Configurazione Email",
|
||||
"fields": {
|
||||
"provider": "Provider",
|
||||
"host": "SMTP Host",
|
||||
"port": "Porta",
|
||||
"user": "Username",
|
||||
"password": "Password",
|
||||
"ssl": "Abilita SSL/TLS",
|
||||
"apiKey": "Resend API Key",
|
||||
"fromEmail": "Email Mittente",
|
||||
"fromName": "Nome Mittente"
|
||||
},
|
||||
"helpers": {
|
||||
"apiKey": "Ottieni la tua API Key su"
|
||||
},
|
||||
"sections": {
|
||||
"defaultSender": "Mittente Default"
|
||||
},
|
||||
"actions": {
|
||||
"testConnection": "Test Connessione",
|
||||
"sendTest": "Invia Test"
|
||||
},
|
||||
"testStats": {
|
||||
"title": "Test Email",
|
||||
"recipient": "Destinatario",
|
||||
"subject": "Oggetto"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "Errore nel caricamento configurazione",
|
||||
"saveSuccess": "Configurazione salvata con successo",
|
||||
"saveError": "Errore nel salvataggio configurazione",
|
||||
"recipientRequired": "Email destinatario obbligatoria per il test",
|
||||
"testSuccess": "Email di test inviata con successo",
|
||||
"testError": "Errore nell'invio email di test"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Log Email",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"date": "Data",
|
||||
"status": "Stato",
|
||||
"sender": "Mittente",
|
||||
"recipient": "Destinatario",
|
||||
"subject": "Oggetto",
|
||||
"error": "Errore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"training": {
|
||||
"title": "Formazione",
|
||||
"dashboard": "Dashboard",
|
||||
"courses": "Corsi",
|
||||
"registry": "Anagrafica Corsi",
|
||||
"matrix": "Matrice Formazione",
|
||||
"expiring": "In Scadenza",
|
||||
"expired": "Scaduti",
|
||||
"valid": "Valido",
|
||||
"validityDays": "Giorni Validità",
|
||||
"newTraining": "Nuova Formazione",
|
||||
"recordDate": "Data Corso",
|
||||
"expirationDate": "Data Scadenza",
|
||||
"certificate": "Attestato",
|
||||
"upload": "Carica",
|
||||
"download": "Scarica",
|
||||
"status": "Stato",
|
||||
"participant": "Partecipante",
|
||||
"course": "Corso",
|
||||
"deleteConfirm": "Eliminare questa formazione?",
|
||||
"daysRemaining": "Giorni rimanenti",
|
||||
"expiringIn": "Scade tra {{days}} giorni",
|
||||
"sendNotification": "Invia Notifica",
|
||||
"notificationSent": "Notifica inviata con successo",
|
||||
"editCourse": "Modifica Corso",
|
||||
"editTraining": "Modifica Formazione"
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,14 @@ import SalesRoutes from "./apps/sales/routes";
|
||||
import ProductionRoutes from "./apps/production/routes";
|
||||
import EventsRoutes from "./apps/events/routes";
|
||||
import HRRoutes from "./apps/hr/routes";
|
||||
import CommunicationsRoutes from "./apps/communications/routes";
|
||||
import TrainingRoutes from "./apps/training/routes";
|
||||
import { AppGuard } from "./components/AppGuard";
|
||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||
import { AppProvider } from "./contexts/AppContext";
|
||||
import { TabProvider } from "./contexts/TabContext";
|
||||
import EmailConfigPage from "./apps/communications/pages/SettingsPage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -81,6 +84,10 @@ function App() {
|
||||
path="admin/custom-fields"
|
||||
element={<CustomFieldsAdminPage />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/email-config"
|
||||
element={<EmailConfigPage />}
|
||||
/>
|
||||
{/* Warehouse Module */}
|
||||
<Route
|
||||
path="warehouse/*"
|
||||
@@ -135,6 +142,24 @@ function App() {
|
||||
</AppGuard>
|
||||
}
|
||||
/>
|
||||
{/* Communications Module */}
|
||||
<Route
|
||||
path="communications/*"
|
||||
element={
|
||||
<AppGuard appCode="communications">
|
||||
<CommunicationsRoutes />
|
||||
</AppGuard>
|
||||
}
|
||||
/>
|
||||
{/* Training Module */}
|
||||
<Route
|
||||
path="training/*"
|
||||
element={
|
||||
<AppGuard appCode="training">
|
||||
<TrainingRoutes />
|
||||
</AppGuard>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</TabProvider>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Box, Paper, Tab, Tabs } from "@mui/material";
|
||||
|
||||
export default function CommunicationsLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const getActiveTab = () => {
|
||||
const path = location.pathname;
|
||||
if (path.includes("/communications/logs")) return "/communications/logs";
|
||||
return "/communications/settings";
|
||||
};
|
||||
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: string) => {
|
||||
navigate(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Tabs
|
||||
value={getActiveTab()}
|
||||
onChange={handleChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
>
|
||||
<Tab label="Configurazione" value="/communications/settings" />
|
||||
<Tab label="Logs" value="/communications/logs" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Box sx={{ flex: 1, overflow: "auto" }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
71
src/frontend/src/apps/communications/pages/LogsPage.tsx
Normal file
71
src/frontend/src/apps/communications/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DataGrid, GridColDef } from '@mui/x-data-grid';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { History } from '@mui/icons-material';
|
||||
import { communicationsService } from '../services/communicationsService';
|
||||
import { EmailLog } from '../types';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, []);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await communicationsService.getLogs(100);
|
||||
setLogs(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: 'id', headerName: t('communications.logs.columns.id'), width: 70 },
|
||||
{
|
||||
field: 'sentDate', headerName: t('communications.logs.columns.date'), width: 180,
|
||||
valueFormatter: (params) => dayjs(params.value).format('DD/MM/YYYY HH:mm')
|
||||
},
|
||||
{
|
||||
field: 'status', headerName: t('communications.logs.columns.status'), width: 120,
|
||||
renderCell: (params) => (
|
||||
<span style={{
|
||||
color: params.value === 'Success' ? 'green' : 'red',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{params.value}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ field: 'sender', headerName: t('communications.logs.columns.sender'), width: 200 },
|
||||
{ field: 'recipient', headerName: t('communications.logs.columns.recipient'), width: 200 },
|
||||
{ field: 'subject', headerName: t('communications.logs.columns.subject'), flex: 1 },
|
||||
{ field: 'errorMessage', headerName: t('communications.logs.columns.error'), width: 200 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box p={3} sx={{ height: '80vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h4"><History /> {t('communications.logs.title')}</Typography>
|
||||
</Box>
|
||||
<DataGrid
|
||||
rows={logs}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
}}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
243
src/frontend/src/apps/communications/pages/SettingsPage.tsx
Normal file
243
src/frontend/src/apps/communications/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
Box, Paper, Typography, TextField, Button, Grid,
|
||||
Switch, FormControlLabel, Divider, Alert, Snackbar,
|
||||
FormControl, InputLabel, Select, MenuItem
|
||||
} from '@mui/material';
|
||||
import { Save, Send, Email } from '@mui/icons-material';
|
||||
import { communicationsService } from '../services/communicationsService';
|
||||
import { SmtpConfig, TestEmail } from '../types';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { control, handleSubmit, reset, watch } = useForm<SmtpConfig>();
|
||||
const provider = watch('provider') || 'smtp';
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testMode, setTestMode] = useState(false);
|
||||
const [testData, setTestData] = useState<TestEmail>({ to: '', subject: 'Test Email', body: 'Test content' });
|
||||
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const config = await communicationsService.getConfig();
|
||||
reset(config);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNotification({ type: 'error', message: t('communications.settings.messages.loadError') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: SmtpConfig) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await communicationsService.saveConfig(data);
|
||||
setNotification({ type: 'success', message: t('communications.settings.messages.saveSuccess') });
|
||||
} catch (error) {
|
||||
setNotification({ type: 'error', message: t('communications.settings.messages.saveError') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendTest = async () => {
|
||||
if (!testData.to) {
|
||||
setNotification({ type: 'error', message: t('communications.settings.messages.recipientRequired') });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await communicationsService.sendTestEmail(testData);
|
||||
setNotification({ type: 'success', message: t('communications.settings.messages.testSuccess') });
|
||||
setTestMode(false);
|
||||
} catch (error: any) {
|
||||
setNotification({ type: 'error', message: error.response?.data?.message || t('communications.settings.messages.testError') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Typography variant="h4" gutterBottom display="flex" alignItems="center" gap={2}>
|
||||
<Email fontSize="large" color="primary" /> {t('communications.settings.title')}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('communications.settings.fields.provider')}</InputLabel>
|
||||
<Controller
|
||||
name="provider"
|
||||
control={control}
|
||||
defaultValue="smtp"
|
||||
render={({ field }) => (
|
||||
<Select {...field} label={t('communications.settings.fields.provider')}>
|
||||
<MenuItem value="smtp">SMTP</MenuItem>
|
||||
<MenuItem value="resend">Resend</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{provider === 'smtp' && (
|
||||
<>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Controller
|
||||
name="host"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.host')} fullWidth required />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Controller
|
||||
name="port"
|
||||
control={control}
|
||||
defaultValue={587}
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.port')} type="number" fullWidth required />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
name="user"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.user')} fullWidth />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.password')} type="password" fullWidth />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Controller
|
||||
name="enableSsl"
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={value} onChange={onChange} />}
|
||||
label={t('communications.settings.fields.ssl')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider === 'resend' && (
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="resendApiKey"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.apiKey')} type="password" fullWidth required />}
|
||||
/>
|
||||
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{t('communications.settings.helpers.apiKey')} <a href="https://resend.com/api-keys" target="_blank" rel="noopener noreferrer">resend.com</a>
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="h6">{t('communications.settings.sections.defaultSender')}</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
name="fromEmail"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.fromEmail')} fullWidth required />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Controller
|
||||
name="fromName"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => <TextField {...field} label={t('communications.settings.fields.fromName')} fullWidth />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Send />}
|
||||
onClick={() => setTestMode(!testMode)}
|
||||
>
|
||||
{t('communications.settings.actions.testConnection')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<Save />}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
{testMode && (
|
||||
<Paper sx={{ p: 3, bgcolor: '#f5f5f5' }}>
|
||||
<Typography variant="h6" gutterBottom>{t('communications.settings.testStats.title')}</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t('communications.settings.testStats.recipient')}
|
||||
fullWidth
|
||||
value={testData.to}
|
||||
onChange={(e) => setTestData({ ...testData, to: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
label={t('communications.settings.testStats.subject')}
|
||||
fullWidth
|
||||
value={testData.subject}
|
||||
onChange={(e) => setTestData({ ...testData, subject: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button variant="contained" color="secondary" onClick={sendTest} disabled={loading}>
|
||||
{t('communications.settings.actions.sendTest')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={!!notification}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setNotification(null)}
|
||||
>
|
||||
<Alert severity={notification?.type || 'info'} onClose={() => setNotification(null)}>
|
||||
{notification?.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
16
src/frontend/src/apps/communications/routes.tsx
Normal file
16
src/frontend/src/apps/communications/routes.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import LogsPage from "./pages/LogsPage";
|
||||
import CommunicationsLayout from "./components/CommunicationsLayout";
|
||||
|
||||
export default function CommunicationsRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<CommunicationsLayout />}>
|
||||
<Route index element={<Navigate to="settings" replace />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import api from '../../../services/api';
|
||||
import { SmtpConfig, TestEmail, EmailLog } from '../types';
|
||||
|
||||
export const communicationsService = {
|
||||
getConfig: async () => {
|
||||
const response = await api.get<SmtpConfig>('/communications/config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
saveConfig: async (config: SmtpConfig) => {
|
||||
await api.post('/communications/config', config);
|
||||
},
|
||||
|
||||
sendTestEmail: async (data: TestEmail) => {
|
||||
await api.post('/communications/send-test', data);
|
||||
},
|
||||
|
||||
getLogs: async (limit: number = 50) => {
|
||||
const response = await api.get<EmailLog[]>('/communications/logs', { params: { limit } });
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
27
src/frontend/src/apps/communications/types/index.ts
Normal file
27
src/frontend/src/apps/communications/types/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password?: string;
|
||||
enableSsl: boolean;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
provider?: 'smtp' | 'resend';
|
||||
resendApiKey?: string;
|
||||
}
|
||||
|
||||
export interface TestEmail {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface EmailLog {
|
||||
id: number;
|
||||
sentDate: string;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -221,10 +221,10 @@ export default function AssenzePage() {
|
||||
onChange={(e) => setFormData({ ...formData, tipoAssenza: e.target.value })}
|
||||
required
|
||||
>
|
||||
<MenuItem value="Ferie">Ferie</MenuItem>
|
||||
<MenuItem value="Malattia">Malattia</MenuItem>
|
||||
<MenuItem value="Permesso">Permesso</MenuItem>
|
||||
<MenuItem value="Altro">Altro</MenuItem>
|
||||
<MenuItem value="Ferie">{t('personale.assenza.ferie')}</MenuItem>
|
||||
<MenuItem value="Malattia">{t('personale.assenza.malattia')}</MenuItem>
|
||||
<MenuItem value="Permesso">{t('personale.assenza.permesso')}</MenuItem>
|
||||
<MenuItem value="Altro">{t('personale.assenza.altro')}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
@@ -236,9 +236,9 @@ export default function AssenzePage() {
|
||||
onChange={(e) => setFormData({ ...formData, stato: e.target.value })}
|
||||
required
|
||||
>
|
||||
<MenuItem value="Richiesta">Richiesta</MenuItem>
|
||||
<MenuItem value="Approvata">Approvata</MenuItem>
|
||||
<MenuItem value="Rifiutata">Rifiutata</MenuItem>
|
||||
<MenuItem value="Richiesta">{t('personale.status.richiesta')}</MenuItem>
|
||||
<MenuItem value="Approvata">{t('personale.status.approvata')}</MenuItem>
|
||||
<MenuItem value="Rifiutata">{t('personale.status.rifiutata')}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
|
||||
@@ -243,10 +243,10 @@ export default function RimborsiPage() {
|
||||
onChange={(e) => setFormData({ ...formData, stato: e.target.value })}
|
||||
required
|
||||
>
|
||||
<MenuItem value="Richiesto">Richiesto</MenuItem>
|
||||
<MenuItem value="Approvato">Approvato</MenuItem>
|
||||
<MenuItem value="Rimborsato">Rimborsato</MenuItem>
|
||||
<MenuItem value="Rifiutato">Rifiutato</MenuItem>
|
||||
<MenuItem value="Richiesto">{t('personale.status.richiesto')}</MenuItem>
|
||||
<MenuItem value="Approvato">{t('personale.status.approvato')}</MenuItem>
|
||||
<MenuItem value="Rimborsato">{t('personale.status.rimborsato')}</MenuItem>
|
||||
<MenuItem value="Rifiutato">{t('personale.status.rifiutato')}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
import { ShoppingCart as PurchaseIcon } from '@mui/icons-material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function PurchasesStatsWidget() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<PurchaseIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="h6">Purchases</Typography>
|
||||
<Typography variant="h6">{t('purchases.stats.title')}</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4" sx={{ mb: 1 }}>€ 8,320</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Costs this month</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('purchases.stats.costsThisMonth')}</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" color="warning.main">Pending Orders: 3</Typography>
|
||||
<Typography variant="body2" color="warning.main">
|
||||
{t('purchases.stats.pendingOrders', { count: 3 })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -184,11 +184,11 @@ export default function PurchaseOrderFormPage() {
|
||||
</Button>
|
||||
<Box>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? `${t("purchases.orders.editOrder")} ${order?.orderNumber}` : t("purchases.orders.newOrder")}
|
||||
{isEdit ? `${t("purchases.order.editTitle")} ${order?.orderNumber}` : t("purchases.order.newOrder")}
|
||||
</Typography>
|
||||
{isEdit && order && (
|
||||
<Chip
|
||||
label={t(`purchases.orders.status.${PurchaseOrderStatus[order.status]}`)}
|
||||
label={t(`purchases.order.status.${PurchaseOrderStatus[order.status]}`)}
|
||||
color={order.status === PurchaseOrderStatus.Confirmed ? "primary" : order.status === PurchaseOrderStatus.Received ? "success" : "default"}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
@@ -206,7 +206,7 @@ export default function PurchaseOrderFormPage() {
|
||||
onClick={() => confirmMutation.mutate(Number(id))}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{t("purchases.orders.actions.confirm")}
|
||||
{t("purchases.order.actions.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function PurchaseOrderFormPage() {
|
||||
onClick={() => receiveMutation.mutate(Number(id))}
|
||||
disabled={receiveMutation.isPending}
|
||||
>
|
||||
{t("purchases.orders.actions.receive")}
|
||||
{t("purchases.order.actions.receive")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function PurchaseOrderFormPage() {
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("purchases.orders.fields.orderDate")}
|
||||
label={t("purchases.order.fields.date")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
@@ -264,7 +264,7 @@ export default function PurchaseOrderFormPage() {
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("purchases.orders.fields.expectedDeliveryDate")}
|
||||
label={t("purchases.order.fields.expectedDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
@@ -288,7 +288,7 @@ export default function PurchaseOrderFormPage() {
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("purchases.orders.fields.supplier")}
|
||||
label={t("purchases.order.fields.supplier")}
|
||||
error={!!errors.supplierId}
|
||||
helperText={errors.supplierId?.message}
|
||||
/>
|
||||
@@ -311,7 +311,7 @@ export default function PurchaseOrderFormPage() {
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("purchases.orders.fields.destinationWarehouse")}
|
||||
label={t("purchases.order.fields.warehouse")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -325,7 +325,7 @@ export default function PurchaseOrderFormPage() {
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.orders.fields.notes")}
|
||||
label={t("purchases.order.fields.notes")}
|
||||
fullWidth
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
@@ -337,7 +337,7 @@ export default function PurchaseOrderFormPage() {
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}>
|
||||
<Typography variant="h6">{t("purchases.orders.fields.lineTotal")}</Typography>
|
||||
<Typography variant="h6">{t("purchases.order.lines.total")}</Typography>
|
||||
{!isReadOnly && (
|
||||
<Button startIcon={<AddIcon />} onClick={() => append({
|
||||
warehouseArticleId: 0,
|
||||
@@ -356,12 +356,12 @@ export default function PurchaseOrderFormPage() {
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="30%">{t("purchases.orders.fields.article")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.orders.fields.quantity")}</TableCell>
|
||||
<TableCell width="15%">{t("purchases.orders.fields.unitPrice")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.orders.fields.discount")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.orders.fields.taxRate")}</TableCell>
|
||||
<TableCell width="15%" align="right">{t("purchases.orders.fields.lineTotal")}</TableCell>
|
||||
<TableCell width="30%">{t("purchases.order.lines.article")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.order.lines.quantity")}</TableCell>
|
||||
<TableCell width="15%">{t("purchases.order.lines.price")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.order.lines.discount")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.order.lines.tax")}</TableCell>
|
||||
<TableCell width="15%" align="right">{t("purchases.order.lines.total")}</TableCell>
|
||||
<TableCell width="10%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -464,7 +464,7 @@ export default function PurchaseOrderFormPage() {
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="right">
|
||||
<Typography fontWeight="bold">{t("purchases.orders.totals.gross")}</Typography>
|
||||
<Typography fontWeight="bold">{t("purchases.order.total")}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">
|
||||
|
||||
@@ -56,15 +56,15 @@ export default function SuppliersPage() {
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "code", headerName: t("purchases.suppliers.columns.code"), width: 120 },
|
||||
{ field: "name", headerName: t("purchases.suppliers.columns.name"), flex: 1, minWidth: 200 },
|
||||
{ field: "vatNumber", headerName: t("purchases.suppliers.columns.vatNumber"), width: 150 },
|
||||
{ field: "email", headerName: t("purchases.suppliers.columns.email"), width: 200 },
|
||||
{ field: "phone", headerName: t("purchases.suppliers.columns.phone"), width: 150 },
|
||||
{ field: "city", headerName: t("purchases.suppliers.columns.city"), width: 150 },
|
||||
{ field: "code", headerName: t("purchases.supplier.columns.code"), width: 120 },
|
||||
{ field: "name", headerName: t("purchases.supplier.columns.name"), flex: 1, minWidth: 200 },
|
||||
{ field: "vatNumber", headerName: t("purchases.supplier.columns.vatNumber"), width: 150 },
|
||||
{ field: "email", headerName: t("purchases.supplier.columns.email"), width: 200 },
|
||||
{ field: "phone", headerName: t("purchases.supplier.columns.phone"), width: 150 },
|
||||
{ field: "city", headerName: t("purchases.supplier.columns.city"), width: 150 },
|
||||
{
|
||||
field: "isActive",
|
||||
headerName: t("purchases.suppliers.columns.status"),
|
||||
headerName: t("purchases.supplier.columns.status"),
|
||||
width: 120,
|
||||
renderCell: (params: GridRenderCellParams<SupplierDto>) => (
|
||||
<Chip
|
||||
@@ -110,13 +110,13 @@ export default function SuppliersPage() {
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">{t("purchases.suppliers.title")}</Typography>
|
||||
<Typography variant="h4">{t("purchases.supplier.title")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("purchases.suppliers.newSupplier")}
|
||||
{t("purchases.supplier.newSupplier")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
Save as SaveIcon,
|
||||
Dataset as DatasetIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
virtualDatasetService,
|
||||
@@ -91,6 +92,7 @@ export default function DatasetManagerDialog({
|
||||
onClose,
|
||||
onDatasetCreated,
|
||||
}: DatasetManagerDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
@@ -189,7 +191,7 @@ export default function DatasetManagerDialog({
|
||||
} catch {
|
||||
setValidationResult({
|
||||
isValid: false,
|
||||
errors: ["Errore durante la validazione"],
|
||||
errors: [t('reports.datasetManager.validationError')],
|
||||
warnings: [],
|
||||
});
|
||||
return false;
|
||||
@@ -230,7 +232,7 @@ export default function DatasetManagerDialog({
|
||||
};
|
||||
|
||||
const handleDeleteDataset = async (dataset: VirtualDatasetDto) => {
|
||||
if (confirm(`Eliminare il dataset "${dataset.displayName}"?`)) {
|
||||
if (confirm(t('reports.datasetManager.deleteConfirm', { name: dataset.displayName }))) {
|
||||
await deleteMutation.mutateAsync(dataset.id);
|
||||
}
|
||||
};
|
||||
@@ -365,13 +367,13 @@ export default function DatasetManagerDialog({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Dataset Virtuali</Typography>
|
||||
<Typography variant="h6">{t('reports.datasetManager.title')}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleNewDataset}
|
||||
>
|
||||
Nuovo Dataset
|
||||
{t('reports.datasetManager.newDataset')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -379,18 +381,17 @@ export default function DatasetManagerDialog({
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
<DatasetIcon sx={{ fontSize: 64, color: "grey.400", mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Nessun Dataset Virtuale
|
||||
{t('reports.datasetManager.noDatasets')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Crea dataset virtuali per combinare e filtrare i dati da più
|
||||
sorgenti.
|
||||
{t('reports.datasetManager.noDatasetsDesc')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleNewDataset}
|
||||
>
|
||||
Crea il primo dataset
|
||||
{t('reports.datasetManager.createFirst')}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -400,17 +401,17 @@ export default function DatasetManagerDialog({
|
||||
key={dataset.id}
|
||||
secondaryAction={
|
||||
<Box>
|
||||
<Tooltip title="Modifica">
|
||||
<Tooltip title={t('reports.toolbar.edit')}>
|
||||
<IconButton onClick={() => handleEditDataset(dataset)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplica">
|
||||
<Tooltip title={t('reports.toolbar.duplicate')}>
|
||||
<IconButton onClick={() => handleCloneDataset(dataset)}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Elimina">
|
||||
<Tooltip title={t('reports.toolbar.delete')}>
|
||||
<IconButton
|
||||
onClick={() => handleDeleteDataset(dataset)}
|
||||
color="error"
|
||||
@@ -428,7 +429,7 @@ export default function DatasetManagerDialog({
|
||||
primary={dataset.displayName}
|
||||
secondary={
|
||||
<Box component="span">
|
||||
{dataset.descrizione || "Nessuna descrizione"}
|
||||
{dataset.descrizione || t('reports.datasetManager.noDescription')}
|
||||
<Box component="span" sx={{ display: "block", mt: 0.5 }}>
|
||||
<Chip
|
||||
label={dataset.categoria}
|
||||
@@ -436,7 +437,7 @@ export default function DatasetManagerDialog({
|
||||
sx={{ height: 20, fontSize: "0.7rem" }}
|
||||
/>
|
||||
<Chip
|
||||
label={`${dataset.configuration?.sources.length || 0} sorgenti`}
|
||||
label={`${dataset.configuration?.sources.length || 0} ${t('reports.datasetManager.sourcesCount')}`}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
@@ -463,7 +464,7 @@ export default function DatasetManagerDialog({
|
||||
{/* Header */}
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
|
||||
<Typography variant="h6">
|
||||
{selectedDataset ? "Modifica Dataset" : "Nuovo Dataset Virtuale"}
|
||||
{selectedDataset ? t('reports.datasetManager.editDataset') : t('reports.datasetManager.newVirtualDataset')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -472,7 +473,7 @@ export default function DatasetManagerDialog({
|
||||
<Box sx={{ px: 2, pt: 2 }}>
|
||||
{validationResult.errors.length > 0 && (
|
||||
<Alert severity="error" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2">Errori:</Typography>
|
||||
<Typography variant="subtitle2">{t('reports.datasetManager.errors')}</Typography>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{validationResult.errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
@@ -482,7 +483,7 @@ export default function DatasetManagerDialog({
|
||||
)}
|
||||
{validationResult.warnings.length > 0 && (
|
||||
<Alert severity="warning">
|
||||
<Typography variant="subtitle2">Avvisi:</Typography>
|
||||
<Typography variant="subtitle2">{t('reports.datasetManager.warnings')}</Typography>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{validationResult.warnings.map((warn, i) => (
|
||||
<li key={i}>{warn}</li>
|
||||
@@ -492,7 +493,7 @@ export default function DatasetManagerDialog({
|
||||
)}
|
||||
{validationResult.isValid &&
|
||||
validationResult.warnings.length === 0 && (
|
||||
<Alert severity="success">Configurazione valida</Alert>
|
||||
<Alert severity="success">{t('reports.datasetManager.validConfig')}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
@@ -503,24 +504,24 @@ export default function DatasetManagerDialog({
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||
>
|
||||
<Tab label="Info" icon={<DatasetIcon />} iconPosition="start" />
|
||||
<Tab label={t('reports.datasetManager.tabs.info')} icon={<DatasetIcon />} iconPosition="start" />
|
||||
<Tab
|
||||
label={`Sorgenti (${editingConfig.sources.length})`}
|
||||
label={`${t('reports.datasetManager.tabs.sources')} (${editingConfig.sources.length})`}
|
||||
icon={<TableIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
label={`Relazioni (${editingConfig.relationships.length})`}
|
||||
label={`${t('reports.datasetManager.tabs.relationships')} (${editingConfig.relationships.length})`}
|
||||
icon={<LinkIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
label={`Filtri (${editingConfig.filters.filter((f) => f.enabled).length})`}
|
||||
label={`${t('reports.datasetManager.tabs.filters')} (${editingConfig.filters.filter((f) => f.enabled).length})`}
|
||||
icon={<FilterIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
label={`Campi (${editingConfig.outputFields.filter((f) => f.included).length})`}
|
||||
label={`${t('reports.datasetManager.tabs.fields')} (${editingConfig.outputFields.filter((f) => f.included).length})`}
|
||||
icon={<FieldsIcon />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
@@ -536,8 +537,9 @@ export default function DatasetManagerDialog({
|
||||
maxWidth: 500,
|
||||
}}
|
||||
>
|
||||
|
||||
<TextField
|
||||
label="Nome Identificativo"
|
||||
label={t('reports.datasetManager.fields.nameId')}
|
||||
value={editingInfo.nome}
|
||||
onChange={(e) =>
|
||||
setEditingInfo((prev) => ({
|
||||
@@ -546,10 +548,10 @@ export default function DatasetManagerDialog({
|
||||
}))
|
||||
}
|
||||
required
|
||||
helperText="Nome univoco usato internamente (senza spazi)"
|
||||
helperText={t('reports.datasetManager.fields.nameIdHelper')}
|
||||
/>
|
||||
<TextField
|
||||
label="Nome Visualizzato"
|
||||
label={t('reports.datasetManager.fields.displayName')}
|
||||
value={editingInfo.displayName}
|
||||
onChange={(e) =>
|
||||
setEditingInfo((prev) => ({
|
||||
@@ -560,7 +562,7 @@ export default function DatasetManagerDialog({
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Descrizione"
|
||||
label={t('reports.datasetManager.fields.description')}
|
||||
value={editingInfo.descrizione}
|
||||
onChange={(e) =>
|
||||
setEditingInfo((prev) => ({
|
||||
@@ -572,10 +574,10 @@ export default function DatasetManagerDialog({
|
||||
rows={2}
|
||||
/>
|
||||
<FormControl>
|
||||
<InputLabel>Categoria</InputLabel>
|
||||
<InputLabel>{t('reports.datasetManager.fields.category')}</InputLabel>
|
||||
<Select
|
||||
value={editingInfo.categoria}
|
||||
label="Categoria"
|
||||
label={t('reports.datasetManager.fields.category')}
|
||||
onChange={(e) =>
|
||||
setEditingInfo((prev) => ({
|
||||
...prev,
|
||||
@@ -591,10 +593,10 @@ export default function DatasetManagerDialog({
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<InputLabel>Icona</InputLabel>
|
||||
<InputLabel>{t('reports.datasetManager.fields.icon')}</InputLabel>
|
||||
<Select
|
||||
value={editingInfo.icon}
|
||||
label="Icona"
|
||||
label={t('reports.datasetManager.fields.icon')}
|
||||
onChange={(e) =>
|
||||
setEditingInfo((prev) => ({ ...prev, icon: e.target.value }))
|
||||
}
|
||||
@@ -640,7 +642,7 @@ export default function DatasetManagerDialog({
|
||||
{/* Dataset disponibili */}
|
||||
<Paper variant="outlined" sx={{ width: 280, p: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Dataset Disponibili
|
||||
{t('reports.datasetManager.sources.available')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
@@ -648,7 +650,7 @@ export default function DatasetManagerDialog({
|
||||
display="block"
|
||||
mb={2}
|
||||
>
|
||||
Clicca per aggiungere una sorgente
|
||||
{t('reports.datasetManager.sources.addInstruction')}
|
||||
</Typography>
|
||||
<List dense>
|
||||
{availableBaseDatasets.map((dataset) => (
|
||||
@@ -677,11 +679,11 @@ export default function DatasetManagerDialog({
|
||||
{/* Sorgenti selezionate */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Sorgenti nel Dataset ({editingConfig.sources.length})
|
||||
{t('reports.datasetManager.sources.inDataset')} ({editingConfig.sources.length})
|
||||
</Typography>
|
||||
{editingConfig.sources.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
Aggiungi almeno una sorgente dati dal pannello a sinistra
|
||||
{t('reports.datasetManager.sources.empty')}
|
||||
</Alert>
|
||||
) : (
|
||||
<List>
|
||||
@@ -725,7 +727,7 @@ export default function DatasetManagerDialog({
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Alias"
|
||||
label={t('reports.datasetManager.sources.alias')}
|
||||
size="small"
|
||||
value={source.alias}
|
||||
onChange={(e) =>
|
||||
@@ -737,7 +739,7 @@ export default function DatasetManagerDialog({
|
||||
{source.isPrimary ? (
|
||||
<Chip
|
||||
icon={<CheckIcon />}
|
||||
label="Primario"
|
||||
label={t('reports.datasetManager.sources.primary')}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
@@ -746,7 +748,7 @@ export default function DatasetManagerDialog({
|
||||
size="small"
|
||||
onClick={() => handleSetPrimary(source.id)}
|
||||
>
|
||||
Imposta Primario
|
||||
{t('reports.datasetManager.sources.setPrimary')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
History as HistoryIcon,
|
||||
AutoMode as AutoSaveIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ElementType } from "../../../../types/report";
|
||||
|
||||
// Snap options type
|
||||
@@ -130,42 +131,42 @@ const ELEMENT_TYPES = [
|
||||
{
|
||||
type: "text" as ElementType,
|
||||
icon: TextIcon,
|
||||
label: "Testo",
|
||||
label: "reports.elements.text",
|
||||
shortcut: "T",
|
||||
color: "#2196f3",
|
||||
description: "Aggiungi un campo di testo",
|
||||
description: "reports.elements.textDesc",
|
||||
},
|
||||
{
|
||||
type: "image" as ElementType,
|
||||
icon: ImageIcon,
|
||||
label: "Immagine",
|
||||
label: "reports.elements.image",
|
||||
shortcut: "I",
|
||||
color: "#9c27b0",
|
||||
description: "Inserisci un'immagine",
|
||||
description: "reports.elements.imageDesc",
|
||||
},
|
||||
{
|
||||
type: "shape" as ElementType,
|
||||
icon: ShapeIcon,
|
||||
label: "Forma",
|
||||
label: "reports.elements.shape",
|
||||
shortcut: "R",
|
||||
color: "#ff9800",
|
||||
description: "Disegna una forma geometrica",
|
||||
description: "reports.elements.shapeDesc",
|
||||
},
|
||||
{
|
||||
type: "table" as ElementType,
|
||||
icon: TableIcon,
|
||||
label: "Tabella",
|
||||
label: "reports.elements.table",
|
||||
shortcut: "B",
|
||||
color: "#4caf50",
|
||||
description: "Inserisci una tabella dati",
|
||||
description: "reports.elements.tableDesc",
|
||||
},
|
||||
{
|
||||
type: "line" as ElementType,
|
||||
icon: LineIcon,
|
||||
label: "Linea",
|
||||
label: "reports.elements.line",
|
||||
shortcut: "L",
|
||||
color: "#607d8b",
|
||||
description: "Traccia una linea",
|
||||
description: "reports.elements.lineDesc",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -174,47 +175,37 @@ const SNAP_OPTIONS_CONFIG = [
|
||||
{
|
||||
key: "grid" as keyof SnapOptions,
|
||||
icon: GridSnapIcon,
|
||||
label: "Griglia",
|
||||
description: "Allinea alla griglia",
|
||||
label: "reports.snap.grid",
|
||||
description: "reports.snap.grid",
|
||||
},
|
||||
{
|
||||
key: "objects" as keyof SnapOptions,
|
||||
icon: ObjectSnapIcon,
|
||||
label: "Oggetti",
|
||||
description: "Allinea agli altri oggetti",
|
||||
label: "reports.snap.objects",
|
||||
description: "reports.snap.objects",
|
||||
},
|
||||
{
|
||||
key: "borders" as keyof SnapOptions,
|
||||
icon: BorderSnapIcon,
|
||||
label: "Margini",
|
||||
description: "Allinea ai margini pagina",
|
||||
label: "reports.snap.borders",
|
||||
description: "reports.snap.borders",
|
||||
},
|
||||
{
|
||||
key: "center" as keyof SnapOptions,
|
||||
icon: CenterSnapIcon,
|
||||
label: "Centro",
|
||||
description: "Allinea al centro",
|
||||
label: "reports.snap.center",
|
||||
description: "reports.snap.center",
|
||||
},
|
||||
{
|
||||
key: "tangent" as keyof SnapOptions,
|
||||
icon: TangentSnapIcon,
|
||||
label: "Bordi",
|
||||
description: "Allinea ai bordi adiacenti",
|
||||
label: "reports.snap.tangent",
|
||||
description: "reports.snap.tangent",
|
||||
},
|
||||
];
|
||||
|
||||
// Format time ago
|
||||
function formatTimeAgo(date: Date | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "Ora";
|
||||
if (minutes < 60) return `${minutes}m fa`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h fa`;
|
||||
return date.toLocaleDateString("it-IT", { day: "2-digit", month: "short" });
|
||||
}
|
||||
|
||||
|
||||
// ToolbarSection component for consistent styling
|
||||
function ToolbarSection({
|
||||
@@ -353,7 +344,20 @@ export default function EditorToolbar({
|
||||
autoSaveEnabled = true,
|
||||
onAutoSaveToggle,
|
||||
}: EditorToolbarProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const formatTimeAgo = (date: Date | null | undefined) => {
|
||||
if (!date) return "";
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return t('reports.time.now');
|
||||
if (minutes < 60) return t('reports.time.minutesAgo', { count: minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return t('reports.time.hoursAgo', { count: hours });
|
||||
return date.toLocaleDateString(i18n.language, { day: "2-digit", month: "short" });
|
||||
};
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const isTablet = useMediaQuery(theme.breakpoints.between("sm", "md"));
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("lg"));
|
||||
@@ -412,8 +416,8 @@ export default function EditorToolbar({
|
||||
<Tooltip
|
||||
title={
|
||||
autoSaveEnabled
|
||||
? "Auto-salvataggio attivo"
|
||||
: "Auto-salvataggio disattivato"
|
||||
? t('reports.toolbar.autoSaveOn')
|
||||
: t('reports.toolbar.autoSaveOff')
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
@@ -439,11 +443,11 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Save status */}
|
||||
{isSaving ? (
|
||||
<Tooltip title="Salvataggio in corso...">
|
||||
<Tooltip title={t('reports.toolbar.saving')}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Salvo...
|
||||
{t('reports.toolbar.saving')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -451,14 +455,14 @@ export default function EditorToolbar({
|
||||
<Tooltip
|
||||
title={
|
||||
autoSaveEnabled
|
||||
? "Salvataggio automatico in attesa..."
|
||||
: "Modifiche non salvate"
|
||||
? t('reports.toolbar.autoSavePending')
|
||||
: t('reports.toolbar.unsavedTooltip')
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<UnsavedIcon fontSize="small" sx={{ color: "warning.main" }} />
|
||||
<Typography variant="caption" color="warning.main">
|
||||
Non salvato
|
||||
{t('reports.toolbar.unsaved')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -466,14 +470,14 @@ export default function EditorToolbar({
|
||||
<Tooltip
|
||||
title={
|
||||
lastSavedAt
|
||||
? `Ultimo salvataggio: ${formatTimeAgo(lastSavedAt)}`
|
||||
: "Salvato"
|
||||
? `${t('reports.toolbar.saved')}: ${formatTimeAgo(lastSavedAt)}`
|
||||
: t('reports.toolbar.saved')
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<SavedIcon fontSize="small" sx={{ color: "success.main" }} />
|
||||
<Typography variant="caption" color="success.main">
|
||||
{formatTimeAgo(lastSavedAt) || "Salvato"}
|
||||
{formatTimeAgo(lastSavedAt) || t('reports.toolbar.saved')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -523,14 +527,14 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<StyledIconButton
|
||||
tooltip="Annulla"
|
||||
tooltip={t('reports.toolbar.undo')}
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<UndoIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip="Ripeti"
|
||||
tooltip={t('reports.toolbar.redo')}
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
@@ -541,7 +545,7 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Delete */}
|
||||
<StyledIconButton
|
||||
tooltip="Elimina"
|
||||
tooltip={t('reports.toolbar.delete')}
|
||||
onClick={onDeleteElement}
|
||||
disabled={!hasSelection}
|
||||
color="#f44336"
|
||||
@@ -571,12 +575,12 @@ export default function EditorToolbar({
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.25 }} />
|
||||
|
||||
{/* Save/Preview */}
|
||||
<StyledIconButton tooltip="Anteprima" onClick={onPreview}>
|
||||
<StyledIconButton tooltip={t('reports.toolbar.preview')} onClick={onPreview}>
|
||||
<PreviewIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
{!autoSaveEnabled && (
|
||||
<StyledIconButton
|
||||
tooltip="Salva"
|
||||
tooltip={t('reports.toolbar.save')}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
color="#1976d2"
|
||||
@@ -615,7 +619,7 @@ export default function EditorToolbar({
|
||||
>
|
||||
{/* Zoom */}
|
||||
<StyledIconButton
|
||||
tooltip="Zoom out"
|
||||
tooltip={t('reports.toolbar.zoomOut')}
|
||||
onClick={() => onZoomChange(Math.max(0.25, zoom - 0.25))}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
@@ -634,7 +638,7 @@ export default function EditorToolbar({
|
||||
{Math.round(zoom * 100)}%
|
||||
</Button>
|
||||
<StyledIconButton
|
||||
tooltip="Zoom in"
|
||||
tooltip={t('reports.toolbar.zoomIn')}
|
||||
onClick={() => onZoomChange(Math.min(3, zoom + 0.25))}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
@@ -644,7 +648,7 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Grid & Snap */}
|
||||
<StyledIconButton
|
||||
tooltip="Griglia"
|
||||
tooltip={t('reports.snap.grid')}
|
||||
onClick={onToggleGrid}
|
||||
active={showGrid}
|
||||
>
|
||||
@@ -655,7 +659,7 @@ export default function EditorToolbar({
|
||||
)}
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip="Snap"
|
||||
tooltip={t('reports.snap.options')}
|
||||
onClick={(e) => setSnapMenuAnchor(e.currentTarget)}
|
||||
active={activeSnapCount > 0}
|
||||
badge={activeSnapCount || undefined}
|
||||
@@ -667,14 +671,14 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Copy/Lock */}
|
||||
<StyledIconButton
|
||||
tooltip="Duplica"
|
||||
tooltip={t('reports.toolbar.duplicate')}
|
||||
onClick={onCopyElement}
|
||||
disabled={!hasSelection}
|
||||
>
|
||||
<CopyIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip={isLocked ? "Sblocca" : "Blocca"}
|
||||
tooltip={isLocked ? t('reports.toolbar.unlock') : t('reports.toolbar.lock')}
|
||||
onClick={onToggleLock}
|
||||
disabled={!hasSelection}
|
||||
active={isLocked}
|
||||
@@ -711,8 +715,8 @@ export default function EditorToolbar({
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={label}
|
||||
secondary={description}
|
||||
primary={t(label)}
|
||||
secondary={t(description)}
|
||||
primaryTypographyProps={{ fontWeight: 500 }}
|
||||
secondaryTypographyProps={{ fontSize: "0.7rem" }}
|
||||
/>
|
||||
@@ -775,7 +779,7 @@ export default function EditorToolbar({
|
||||
mb={1}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Opzioni Snap
|
||||
{t('reports.snap.options')}
|
||||
</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
@@ -798,27 +802,23 @@ export default function EditorToolbar({
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<Icon
|
||||
fontSize="small"
|
||||
color={snapOptions[key] ? "primary" : "inherit"}
|
||||
/>
|
||||
<Icon fontSize="small" color={snapOptions[key] ? "primary" : "inherit"} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={label}
|
||||
primaryTypographyProps={{ variant: "body2" }}
|
||||
/>
|
||||
<Switch size="small" checked={snapOptions[key]} />
|
||||
<ListItemText primary={t(label)} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
|
||||
</Box>
|
||||
</Popover>
|
||||
</Popover >
|
||||
|
||||
{/* Zoom Popover */}
|
||||
<Popover
|
||||
< Popover
|
||||
open={Boolean(zoomMenuAnchor)}
|
||||
anchorEl={zoomMenuAnchor}
|
||||
onClose={() => setZoomMenuAnchor(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
onClose={() => setZoomMenuAnchor(null)
|
||||
}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }
|
||||
}
|
||||
PaperProps={{ sx: { borderRadius: 2 } }}
|
||||
>
|
||||
<Box sx={{ p: 1.5, width: 180 }}>
|
||||
@@ -850,8 +850,8 @@ export default function EditorToolbar({
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
</Paper>
|
||||
</Popover >
|
||||
</Paper >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -883,7 +883,7 @@ export default function EditorToolbar({
|
||||
onClick={(e) => setAddMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2, textTransform: "none", fontWeight: 600 }}
|
||||
>
|
||||
Aggiungi
|
||||
{t('reports.elements.add')}
|
||||
</Button>
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||
@@ -906,14 +906,14 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Selection Actions */}
|
||||
<StyledIconButton
|
||||
tooltip="Duplica"
|
||||
tooltip={t('reports.toolbar.duplicate')}
|
||||
onClick={onCopyElement}
|
||||
disabled={!hasSelection}
|
||||
>
|
||||
<CopyIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip="Elimina"
|
||||
tooltip={t('reports.toolbar.delete')}
|
||||
onClick={onDeleteElement}
|
||||
disabled={!hasSelection}
|
||||
color="#f44336"
|
||||
@@ -921,7 +921,7 @@ export default function EditorToolbar({
|
||||
<DeleteIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip={isLocked ? "Sblocca" : "Blocca"}
|
||||
tooltip={isLocked ? t('reports.toolbar.unlock') : t('reports.toolbar.lock')}
|
||||
onClick={onToggleLock}
|
||||
disabled={!hasSelection}
|
||||
active={isLocked}
|
||||
@@ -938,14 +938,14 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<StyledIconButton
|
||||
tooltip="Annulla (Ctrl+Z)"
|
||||
tooltip={`${t('reports.toolbar.undo')} (Ctrl+Z)`}
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<UndoIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip="Ripeti (Ctrl+Y)"
|
||||
tooltip={`${t('reports.toolbar.redo')} (Ctrl+Y)`}
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
@@ -956,7 +956,7 @@ export default function EditorToolbar({
|
||||
|
||||
{/* View Controls */}
|
||||
<StyledIconButton
|
||||
tooltip="Griglia"
|
||||
tooltip={t('reports.snap.grid')}
|
||||
onClick={onToggleGrid}
|
||||
active={showGrid}
|
||||
>
|
||||
@@ -967,7 +967,7 @@ export default function EditorToolbar({
|
||||
)}
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip="Snap"
|
||||
tooltip={t('reports.snap.options')}
|
||||
onClick={(e) => setSnapMenuAnchor(e.currentTarget)}
|
||||
active={activeSnapCount > 0}
|
||||
badge={activeSnapCount || undefined}
|
||||
@@ -979,7 +979,7 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Zoom */}
|
||||
<StyledIconButton
|
||||
tooltip="Zoom out"
|
||||
tooltip={t('reports.toolbar.zoomOut')}
|
||||
onClick={() => onZoomChange(Math.max(0.25, zoom - 0.25))}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
@@ -998,7 +998,7 @@ export default function EditorToolbar({
|
||||
{Math.round(zoom * 100)}%
|
||||
</Button>
|
||||
<StyledIconButton
|
||||
tooltip="Zoom in"
|
||||
tooltip={t('reports.toolbar.zoomIn')}
|
||||
onClick={() => onZoomChange(Math.min(3, zoom + 0.25))}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
@@ -1008,7 +1008,7 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Page Navigation */}
|
||||
<StyledIconButton
|
||||
tooltip="Pagina precedente"
|
||||
tooltip={t('reports.toolbar.prevPage')}
|
||||
onClick={onPrevPage}
|
||||
disabled={currentPageIndex <= 0}
|
||||
>
|
||||
@@ -1034,7 +1034,7 @@ export default function EditorToolbar({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<StyledIconButton
|
||||
tooltip="Pagina successiva"
|
||||
tooltip={t('reports.toolbar.nextPage')}
|
||||
onClick={onNextPage}
|
||||
disabled={currentPageIndex >= totalPages - 1}
|
||||
>
|
||||
@@ -1051,7 +1051,7 @@ export default function EditorToolbar({
|
||||
onClick={onPreview}
|
||||
sx={{ borderRadius: 2, textTransform: "none" }}
|
||||
>
|
||||
Anteprima
|
||||
{t('reports.toolbar.preview')}
|
||||
</Button>
|
||||
{!autoSaveEnabled && (
|
||||
<Button
|
||||
@@ -1068,7 +1068,7 @@ export default function EditorToolbar({
|
||||
disabled={isSaving}
|
||||
sx={{ borderRadius: 2, textTransform: "none", fontWeight: 600 }}
|
||||
>
|
||||
{isSaving ? "Salvo..." : "Salva"}
|
||||
{isSaving ? t('reports.toolbar.saving') : t('reports.toolbar.save')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1317,7 +1317,7 @@ export default function EditorToolbar({
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Inserisci elemento
|
||||
{t('reports.elements.insert')}
|
||||
</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
{ELEMENT_TYPES.map(
|
||||
@@ -1385,37 +1385,19 @@ export default function EditorToolbar({
|
||||
sx={{ mx: 0.5, height: 32, alignSelf: "center" }}
|
||||
/>
|
||||
|
||||
{/* Quick Add Toolbar */}
|
||||
<ToolbarSection label={isSmallScreen ? undefined : "INSERISCI"}>
|
||||
{ELEMENT_TYPES.map(({ type, icon: Icon, label, color }) => (
|
||||
<StyledIconButton
|
||||
key={type}
|
||||
tooltip={`${label} (${ELEMENT_TYPES.find((e) => e.type === type)?.shortcut})`}
|
||||
onClick={() => onAddElement(type)}
|
||||
color={color}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
))}
|
||||
</ToolbarSection>
|
||||
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 0.5, height: 32, alignSelf: "center" }}
|
||||
/>
|
||||
|
||||
{/* Selection Actions */}
|
||||
<ToolbarSection label={isSmallScreen ? undefined : "MODIFICA"}>
|
||||
<ToolbarSection label={isSmallScreen ? undefined : t('reports.toolbar.edit')}>
|
||||
<StyledIconButton
|
||||
tooltip="Duplica (Ctrl+D)"
|
||||
tooltip={`${t('reports.toolbar.duplicate')} (Ctrl+D)`}
|
||||
onClick={onCopyElement}
|
||||
disabled={!hasSelection}
|
||||
>
|
||||
<CopyIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip="Elimina (Canc)"
|
||||
tooltip={`${t('reports.toolbar.delete')} (Canc)`}
|
||||
onClick={onDeleteElement}
|
||||
disabled={!hasSelection}
|
||||
color="#f44336"
|
||||
@@ -1423,7 +1405,7 @@ export default function EditorToolbar({
|
||||
<DeleteIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip={isLocked ? "Sblocca (Ctrl+L)" : "Blocca (Ctrl+L)"}
|
||||
tooltip={isLocked ? `${t('reports.toolbar.unlock')} (Ctrl+L)` : `${t('reports.toolbar.lock')} (Ctrl+L)`}
|
||||
onClick={onToggleLock}
|
||||
disabled={!hasSelection}
|
||||
active={isLocked}
|
||||
@@ -1444,16 +1426,16 @@ export default function EditorToolbar({
|
||||
/>
|
||||
|
||||
{/* History */}
|
||||
<ToolbarSection label={isSmallScreen ? undefined : "CRONOLOGIA"}>
|
||||
<ToolbarSection label={isSmallScreen ? undefined : t('reports.toolbar.history')}>
|
||||
<StyledIconButton
|
||||
tooltip="Annulla (Ctrl+Z)"
|
||||
tooltip={`${t('reports.toolbar.undo')} (Ctrl+Z)`}
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<UndoIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
<StyledIconButton
|
||||
tooltip="Ripeti (Ctrl+Y)"
|
||||
tooltip={`${t('reports.toolbar.redo')} (Ctrl+Y)`}
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
@@ -1461,7 +1443,7 @@ export default function EditorToolbar({
|
||||
</StyledIconButton>
|
||||
{onOpenHistory && (
|
||||
<StyledIconButton
|
||||
tooltip="Cronologia modifiche"
|
||||
tooltip={t('reports.toolbar.historyTooltip')}
|
||||
onClick={onOpenHistory}
|
||||
>
|
||||
<HistoryIcon fontSize="small" />
|
||||
@@ -1476,9 +1458,9 @@ export default function EditorToolbar({
|
||||
/>
|
||||
|
||||
{/* View Controls */}
|
||||
<ToolbarSection label={isSmallScreen ? undefined : "VISTA"}>
|
||||
<ToolbarSection label={isSmallScreen ? undefined : t('reports.toolbar.view')}>
|
||||
<StyledIconButton
|
||||
tooltip={showGrid ? "Nascondi griglia (G)" : "Mostra griglia (G)"}
|
||||
tooltip={showGrid ? `${t('reports.snap.hideGrid')} (G)` : `${t('reports.snap.showGrid')} (G)`}
|
||||
onClick={onToggleGrid}
|
||||
active={showGrid}
|
||||
>
|
||||
@@ -1516,7 +1498,7 @@ export default function EditorToolbar({
|
||||
},
|
||||
}}
|
||||
>
|
||||
Snap
|
||||
{t('reports.snap.options')}
|
||||
</Button>
|
||||
</ToolbarSection>
|
||||
|
||||
@@ -1536,7 +1518,7 @@ export default function EditorToolbar({
|
||||
mb={1.5}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Allineamento automatico
|
||||
{t('reports.snap.autoAlign')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -1547,7 +1529,7 @@ export default function EditorToolbar({
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tutti
|
||||
{t('reports.snap.all')}
|
||||
</Typography>
|
||||
}
|
||||
labelPlacement="start"
|
||||
@@ -1624,9 +1606,9 @@ export default function EditorToolbar({
|
||||
/>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<ToolbarSection label={isSmallScreen ? undefined : "ZOOM"}>
|
||||
<ToolbarSection label={isSmallScreen ? undefined : t('reports.toolbar.zoom')}>
|
||||
<StyledIconButton
|
||||
tooltip="Riduci zoom (-)"
|
||||
tooltip={`${t('reports.toolbar.zoomOut')} (-)`}
|
||||
onClick={() => onZoomChange(Math.max(0.25, zoom - 0.25))}
|
||||
>
|
||||
<ZoomOutIcon fontSize="small" />
|
||||
@@ -1650,14 +1632,14 @@ export default function EditorToolbar({
|
||||
</Button>
|
||||
|
||||
<StyledIconButton
|
||||
tooltip="Aumenta zoom (+)"
|
||||
tooltip={`${t('reports.toolbar.zoomIn')} (+)`}
|
||||
onClick={() => onZoomChange(Math.min(3, zoom + 0.25))}
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
|
||||
<StyledIconButton
|
||||
tooltip="Adatta alla finestra"
|
||||
tooltip={t('reports.toolbar.fitWindow')}
|
||||
onClick={() => onZoomChange(0.75)}
|
||||
>
|
||||
<FitIcon fontSize="small" />
|
||||
@@ -1678,7 +1660,7 @@ export default function EditorToolbar({
|
||||
gutterBottom
|
||||
sx={{ display: "block" }}
|
||||
>
|
||||
Livello zoom: {Math.round(zoom * 100)}%
|
||||
{t('reports.toolbar.zoomLevel')}: {Math.round(zoom * 100)}%
|
||||
</Typography>
|
||||
<Slider
|
||||
value={zoom}
|
||||
@@ -1695,7 +1677,7 @@ export default function EditorToolbar({
|
||||
color="text.secondary"
|
||||
sx={{ mb: 1, display: "block" }}
|
||||
>
|
||||
Preset
|
||||
{t('reports.toolbar.presets')}
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
{ZOOM_PRESETS.map(({ value, label }) => (
|
||||
@@ -1715,93 +1697,10 @@ export default function EditorToolbar({
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 0.5, height: 32, alignSelf: "center" }}
|
||||
/>
|
||||
|
||||
{/* Page Navigation */}
|
||||
<ToolbarSection label={isSmallScreen ? undefined : "PAGINA"}>
|
||||
<StyledIconButton
|
||||
tooltip="Pagina precedente (PgUp)"
|
||||
onClick={onPrevPage}
|
||||
disabled={currentPageIndex <= 0}
|
||||
>
|
||||
<PrevPageIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => setPageMenuAnchor(e.currentTarget)}
|
||||
endIcon={<DropdownIcon />}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
borderRadius: 1.5,
|
||||
textTransform: "none",
|
||||
bgcolor: "action.hover",
|
||||
"&:hover": { bgcolor: "action.selected" },
|
||||
}}
|
||||
>
|
||||
<PageIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 0.5, color: "primary.main" }}
|
||||
/>
|
||||
<Typography variant="body2" fontFamily="monospace" fontWeight={600}>
|
||||
{currentPageIndex + 1} / {totalPages}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
<StyledIconButton
|
||||
tooltip="Pagina successiva (PgDn)"
|
||||
onClick={onNextPage}
|
||||
disabled={currentPageIndex >= totalPages - 1}
|
||||
>
|
||||
<NextPageIcon fontSize="small" />
|
||||
</StyledIconButton>
|
||||
</ToolbarSection>
|
||||
|
||||
{/* Page Menu */}
|
||||
<Menu
|
||||
anchorEl={pageMenuAnchor}
|
||||
open={Boolean(pageMenuAnchor)}
|
||||
onClose={() => setPageMenuAnchor(null)}
|
||||
PaperProps={{ sx: { borderRadius: 2, minWidth: 200 } }}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Pagina corrente
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
{currentPageName}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onPrevPage();
|
||||
setPageMenuAnchor(null);
|
||||
}}
|
||||
disabled={currentPageIndex <= 0}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PrevPageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Pagina precedente" secondary="PgUp" />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onNextPage();
|
||||
setPageMenuAnchor(null);
|
||||
}}
|
||||
disabled={currentPageIndex >= totalPages - 1}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<NextPageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Pagina successiva" secondary="PgDn" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
|
||||
<Box flex={1} />
|
||||
|
||||
@@ -1812,7 +1711,7 @@ export default function EditorToolbar({
|
||||
|
||||
{/* Command Palette / Search */}
|
||||
{onOpenCommandPalette && (
|
||||
<Tooltip title="Cerca comando (Ctrl+K)">
|
||||
<Tooltip title={`${t('reports.toolbar.searchCommand')} (Ctrl+K)`}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={onOpenCommandPalette}
|
||||
@@ -1826,14 +1725,14 @@ export default function EditorToolbar({
|
||||
"&:hover": { bgcolor: "action.selected" },
|
||||
}}
|
||||
>
|
||||
Cerca...
|
||||
{t('reports.toolbar.searchCommand')}...
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<StyledIconButton
|
||||
tooltip="Scorciatoie tastiera"
|
||||
tooltip={t('reports.toolbar.shortcuts')}
|
||||
onClick={(e) => setShortcutsAnchor(e.currentTarget)}
|
||||
>
|
||||
<ShortcutsIcon fontSize="small" />
|
||||
@@ -1849,7 +1748,7 @@ export default function EditorToolbar({
|
||||
PaperProps={{ sx: { mt: 1, borderRadius: 2, p: 2, minWidth: 300 } }}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||
Scorciatoie Tastiera
|
||||
{t('reports.toolbar.shortcutsTitle')}
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
<Box
|
||||
@@ -1862,17 +1761,17 @@ export default function EditorToolbar({
|
||||
>
|
||||
<tbody>
|
||||
{[
|
||||
["Ctrl + Z", "Annulla"],
|
||||
["Ctrl + Y", "Ripeti"],
|
||||
["Ctrl + S", "Salva"],
|
||||
["Ctrl + D", "Duplica"],
|
||||
["Ctrl + K", "Cerca comando"],
|
||||
["Canc / Backspace", "Elimina"],
|
||||
["Frecce", "Sposta (1px)"],
|
||||
["Shift + Frecce", "Sposta (10px)"],
|
||||
["G", "Mostra/nascondi griglia"],
|
||||
["+ / -", "Zoom in/out"],
|
||||
["PgUp / PgDn", "Cambia pagina"],
|
||||
["Ctrl + Z", t('reports.toolbar.undo')],
|
||||
["Ctrl + Y", t('reports.toolbar.redo')],
|
||||
["Ctrl + S", t('reports.toolbar.save')],
|
||||
["Ctrl + D", t('reports.toolbar.duplicate')],
|
||||
["Ctrl + K", t('reports.toolbar.searchCommand')],
|
||||
["Canc / Backspace", t('reports.toolbar.delete')],
|
||||
["Frecce", t('reports.shortcuts.move1px')],
|
||||
["Shift + Frecce", t('reports.shortcuts.move10px')],
|
||||
["G", t('reports.shortcuts.toggleGrid')],
|
||||
["+ / -", t('reports.shortcuts.zoomInOut')],
|
||||
["PgUp / PgDn", t('reports.shortcuts.changePage')],
|
||||
].map(([key, action]) => (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Close as CloseIcon,
|
||||
ArrowBack as BackIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { reportGeneratorService } from "../../../../services/reportService";
|
||||
import type {
|
||||
@@ -66,6 +67,7 @@ export default function PreviewDialog({
|
||||
onGeneratePreview,
|
||||
isGenerating,
|
||||
}: PreviewDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
@@ -268,7 +270,7 @@ export default function PreviewDialog({
|
||||
<ListItemText
|
||||
primary={dataset.name}
|
||||
secondary={
|
||||
selectedEntity ? selectedEntity.label : "Non selezionato"
|
||||
selectedEntity ? selectedEntity.label : t('reports.preview.notSelected')
|
||||
}
|
||||
primaryTypographyProps={{
|
||||
variant: "body2",
|
||||
@@ -281,7 +283,7 @@ export default function PreviewDialog({
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Tooltip title="Rimuovi selezione">
|
||||
<Tooltip title={t('reports.preview.removeSelection')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
@@ -327,7 +329,7 @@ export default function PreviewDialog({
|
||||
<IconButton size="small" onClick={() => setMobileShowList(true)}>
|
||||
<BackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="subtitle2">Seleziona</Typography>
|
||||
<Typography variant="subtitle2">{t('reports.preview.select')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box display="flex" alignItems="center" gap={1} mb={1}>
|
||||
@@ -357,7 +359,7 @@ export default function PreviewDialog({
|
||||
|
||||
{/* Ricerca */}
|
||||
<TextField
|
||||
placeholder={`Cerca...`}
|
||||
placeholder={t('reports.preview.searchPlaceholder')}
|
||||
size="small"
|
||||
fullWidth
|
||||
value={searchTerms[activeDataset || ""] || ""}
|
||||
@@ -392,8 +394,8 @@ export default function PreviewDialog({
|
||||
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{searchTerms[activeDataset || ""]
|
||||
? "Nessun risultato trovato"
|
||||
: "Nessuna entità disponibile"}
|
||||
? t('reports.preview.noResults')
|
||||
: t('reports.preview.noEntities')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -482,7 +484,7 @@ export default function PreviewDialog({
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{filteredEntities.length} risultati
|
||||
{filteredEntities.length} {t('reports.preview.results')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -511,7 +513,7 @@ export default function PreviewDialog({
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" sx={{ flex: 1, ml: 1 }}>
|
||||
Anteprima Report
|
||||
{t('reports.preview.title')}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${selectedCount}/${selectedDatasets.length}`}
|
||||
@@ -527,15 +529,15 @@ export default function PreviewDialog({
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Typography variant="h6">Anteprima Report</Typography>
|
||||
<Typography variant="h6">{t('reports.preview.title')}</Typography>
|
||||
<Chip
|
||||
label={`${selectedCount}/${selectedDatasets.length} selezionati`}
|
||||
label={`${selectedCount}/${selectedDatasets.length} ${t('reports.preview.selected')}`}
|
||||
color={allSelected ? "success" : "default"}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Seleziona un'entità per ogni dataset da utilizzare nell'anteprima
|
||||
{t('reports.preview.instruction')}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
)}
|
||||
@@ -545,15 +547,14 @@ export default function PreviewDialog({
|
||||
<DialogContent sx={{ p: 0, display: "flex", overflow: "hidden" }}>
|
||||
{hasError && (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
Errore nel caricamento dei dati disponibili
|
||||
{t('reports.preview.errorLoading')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{selectedDatasets.length === 0 ? (
|
||||
<Box sx={{ p: 3, textAlign: "center", width: "100%" }}>
|
||||
<Alert severity="info">
|
||||
Non ci sono dataset selezionati per questo template. Aggiungi
|
||||
almeno un dataset per poter generare l'anteprima.
|
||||
{t('reports.preview.noDatasets')}
|
||||
</Alert>
|
||||
</Box>
|
||||
) : isMobile ? (
|
||||
@@ -577,7 +578,7 @@ export default function PreviewDialog({
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Seleziona un'entità per ogni dataset
|
||||
{t('reports.preview.selectEntityInstruction')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{renderDatasetList()}
|
||||
@@ -613,7 +614,7 @@ export default function PreviewDialog({
|
||||
|
||||
<DialogActions sx={{ px: isMobile ? 2 : 3, py: 2 }}>
|
||||
<Button onClick={onClose} fullWidth={isMobile}>
|
||||
Annulla
|
||||
{t('reports.preview.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
@@ -625,10 +626,10 @@ export default function PreviewDialog({
|
||||
fullWidth={isMobile}
|
||||
>
|
||||
{isGenerating
|
||||
? "Generazione..."
|
||||
? t('reports.preview.generating')
|
||||
: isMobile
|
||||
? "Genera PDF"
|
||||
: "Genera Anteprima PDF"}
|
||||
? t('reports.preview.generatePdf')
|
||||
: t('reports.preview.generatePreviewPdf')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
|
||||
Tabs,
|
||||
Tab,
|
||||
} from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
@@ -21,10 +22,167 @@ import {
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { clientiService } from "../../../services/lookupService";
|
||||
import { Cliente } from "../../../types";
|
||||
import { Cliente, ClienteContatto } from "../../../types";
|
||||
import { CustomFieldsRenderer } from "../../../components/customFields/CustomFieldsRenderer";
|
||||
import { CustomFieldValues } from "../../../types/customFields";
|
||||
|
||||
function ContactsManager({ clienteId }: { clienteId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<ClienteContatto>>({});
|
||||
|
||||
const { data: contatti = [], isLoading } = useQuery({
|
||||
queryKey: ["clienti", clienteId, "contatti"],
|
||||
queryFn: () => clientiService.getContatti(clienteId),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<ClienteContatto>) =>
|
||||
clientiService.createContatto(clienteId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clienti", clienteId, "contatti"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<ClienteContatto> }) =>
|
||||
clientiService.updateContatto(clienteId, id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clienti", clienteId, "contatti"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => clientiService.deleteContatto(clienteId, id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clienti", clienteId, "contatti"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingId(null);
|
||||
setFormData({});
|
||||
};
|
||||
|
||||
const handleEdit = (contatto: ClienteContatto) => {
|
||||
setFormData(contatto);
|
||||
setEditingId(contatto.id);
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data: formData });
|
||||
} else {
|
||||
createMutation.mutate({ ...formData, clienteId });
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "nome", headerName: t("common.name"), flex: 1 },
|
||||
{ field: "cognome", headerName: t("common.surname"), flex: 1 },
|
||||
{ field: "ruolo", headerName: t("clients.role"), flex: 1 },
|
||||
{ field: "email", headerName: t("clients.email"), flex: 1 },
|
||||
{ field: "telefono", headerName: t("clients.phone"), flex: 1 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<IconButton size="small" onClick={() => handleEdit(params.row)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
if (confirm(t("common.deleteConfirm"))) {
|
||||
deleteMutation.mutate(params.row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box display="flex" justifyContent="flex-end" mb={2}>
|
||||
<Button startIcon={<AddIcon />} variant="contained" onClick={() => setOpenDialog(true)}>
|
||||
{t("clients.newContact")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper sx={{ height: 400, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={contatti}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
pageSizeOptions={[10, 25]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingId ? t("clients.editContact") : t("clients.newContact")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} mt={1}>
|
||||
<TextField
|
||||
label={t("common.name")}
|
||||
value={formData.nome || ""}
|
||||
onChange={(e) => setFormData({ ...formData, nome: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label={t("common.surname")}
|
||||
value={formData.cognome || ""}
|
||||
onChange={(e) => setFormData({ ...formData, cognome: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label={t("clients.role")}
|
||||
value={formData.ruolo || ""}
|
||||
onChange={(e) => setFormData({ ...formData, ruolo: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("clients.email")}
|
||||
value={formData.email || ""}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("clients.phone")}
|
||||
value={formData.telefono || ""}
|
||||
onChange={(e) => setFormData({ ...formData, telefono: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientiPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
@@ -32,6 +190,7 @@ export default function ClientiPage() {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Cliente>>({ attivo: true });
|
||||
const [customFields, setCustomFields] = useState<CustomFieldValues>({});
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const { data: clienti = [], isLoading } = useQuery({
|
||||
queryKey: ["clienti"],
|
||||
@@ -65,6 +224,7 @@ export default function ClientiPage() {
|
||||
setEditingId(null);
|
||||
setFormData({ attivo: true });
|
||||
setCustomFields({});
|
||||
setTabValue(0);
|
||||
};
|
||||
|
||||
const handleEdit = (cliente: Cliente) => {
|
||||
@@ -85,11 +245,9 @@ export default function ClientiPage() {
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
// In modifica, non inviamo il codice (non modificabile)
|
||||
const { codice: _codice, ...updateData } = dataWithCustomFields;
|
||||
updateMutation.mutate({ id: editingId, data: updateData });
|
||||
} else {
|
||||
// In creazione, non inviamo il codice (generato automaticamente)
|
||||
const { codice: _codice, ...createData } = dataWithCustomFields;
|
||||
createMutation.mutate(createData);
|
||||
}
|
||||
@@ -178,192 +336,209 @@ export default function ClientiPage() {
|
||||
{editingId ? t("clients.editClient") : t("clients.newClient")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexWrap="wrap" gap={2} mt={1}>
|
||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.code")}
|
||||
fullWidth
|
||||
value={
|
||||
editingId
|
||||
? formData.codice || ""
|
||||
: t("clients.generatedOnSave")
|
||||
}
|
||||
disabled
|
||||
helperText={
|
||||
editingId
|
||||
? t("clients.autoGenerated")
|
||||
: t("clients.willBeAssigned")
|
||||
}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
sx={
|
||||
!editingId
|
||||
? {
|
||||
"& .MuiInputBase-input.Mui-disabled": {
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
},
|
||||
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} sx={{ mb: 2, borderBottom: 1, borderColor: "divider" }}>
|
||||
<Tab label={t("common.details")} />
|
||||
<Tab label={t("clients.contacts")} disabled={!editingId} />
|
||||
</Tabs>
|
||||
|
||||
<Box role="tabpanel" hidden={tabValue !== 0}>
|
||||
{tabValue === 0 && (
|
||||
<Box display="flex" flexWrap="wrap" gap={2} mt={1}>
|
||||
{/* EXISTING FIELDS */}
|
||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.code")}
|
||||
fullWidth
|
||||
value={
|
||||
editingId
|
||||
? formData.codice || ""
|
||||
: t("clients.generatedOnSave")
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.altCode")}
|
||||
fullWidth
|
||||
value={formData.codiceAlternativo || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codiceAlternativo: e.target.value,
|
||||
})
|
||||
}
|
||||
helperText={t("common.optional")}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.businessName")}
|
||||
fullWidth
|
||||
required
|
||||
value={formData.ragioneSociale || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, ragioneSociale: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.address")}
|
||||
fullWidth
|
||||
value={formData.indirizzo || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, indirizzo: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.zip")}
|
||||
fullWidth
|
||||
value={formData.cap || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cap: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.city")}
|
||||
fullWidth
|
||||
value={formData.citta || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, citta: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.province")}
|
||||
fullWidth
|
||||
value={formData.provincia || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, provincia: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.phone")}
|
||||
fullWidth
|
||||
value={formData.telefono || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, telefono: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.email")}
|
||||
fullWidth
|
||||
type="email"
|
||||
value={formData.email || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.pec")}
|
||||
fullWidth
|
||||
value={formData.pec || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, pec: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.fiscalCode")}
|
||||
fullWidth
|
||||
value={formData.codiceFiscale || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, codiceFiscale: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.vat")}
|
||||
fullWidth
|
||||
value={formData.partitaIva || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, partitaIva: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.recipientCode")}
|
||||
fullWidth
|
||||
value={formData.codiceDestinatario || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codiceDestinatario: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis="100%">
|
||||
<TextField
|
||||
label={t("common.notes")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={formData.note || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, note: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis="100%">
|
||||
<CustomFieldsRenderer
|
||||
entityName="Cliente"
|
||||
values={customFields}
|
||||
onChange={(field, value) => setCustomFields(prev => ({ ...prev, [field]: value }))}
|
||||
/>
|
||||
</Box>
|
||||
disabled
|
||||
helperText={
|
||||
editingId
|
||||
? t("clients.autoGenerated")
|
||||
: t("clients.willBeAssigned")
|
||||
}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
sx={
|
||||
!editingId
|
||||
? {
|
||||
"& .MuiInputBase-input.Mui-disabled": {
|
||||
fontStyle: "italic",
|
||||
color: "text.secondary",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '23%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.altCode")}
|
||||
fullWidth
|
||||
value={formData.codiceAlternativo || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codiceAlternativo: e.target.value,
|
||||
})
|
||||
}
|
||||
helperText={t("common.optional")}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.businessName")}
|
||||
fullWidth
|
||||
required
|
||||
value={formData.ragioneSociale || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, ragioneSociale: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.address")}
|
||||
fullWidth
|
||||
value={formData.indirizzo || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, indirizzo: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.zip")}
|
||||
fullWidth
|
||||
value={formData.cap || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cap: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '65%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.city")}
|
||||
fullWidth
|
||||
value={formData.citta || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, citta: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '32%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.province")}
|
||||
fullWidth
|
||||
value={formData.provincia || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, provincia: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.phone")}
|
||||
fullWidth
|
||||
value={formData.telefono || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, telefono: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.email")}
|
||||
fullWidth
|
||||
type="email"
|
||||
value={formData.email || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.pec")}
|
||||
fullWidth
|
||||
value={formData.pec || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, pec: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.fiscalCode")}
|
||||
fullWidth
|
||||
value={formData.codiceFiscale || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, codiceFiscale: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.vat")}
|
||||
fullWidth
|
||||
value={formData.partitaIva || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, partitaIva: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis={{ xs: '100%', md: '48%' }} flexGrow={1}>
|
||||
<TextField
|
||||
label={t("clients.recipientCode")}
|
||||
fullWidth
|
||||
value={formData.codiceDestinatario || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
codiceDestinatario: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis="100%">
|
||||
<TextField
|
||||
label={t("common.notes")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={formData.note || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, note: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexBasis="100%">
|
||||
<CustomFieldsRenderer
|
||||
entityName="Cliente"
|
||||
values={customFields}
|
||||
onChange={(field, value) => setCustomFields(prev => ({ ...prev, [field]: value }))}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box role="tabpanel" hidden={tabValue !== 1}>
|
||||
{tabValue === 1 && editingId && <ContactsManager clienteId={editingId} />}
|
||||
</Box>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{editingId ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
<Button onClick={handleCloseDialog}>{t("common.close")}</Button>
|
||||
{tabValue === 0 && (
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{editingId ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
|
||||
13
src/frontend/src/apps/training/components/TrainingLayout.tsx
Normal file
13
src/frontend/src/apps/training/components/TrainingLayout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default function TrainingLayout() {
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", p: 2 }}>
|
||||
<Box sx={{ flex: 1, overflow: "hidden" }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
112
src/frontend/src/apps/training/pages/DashboardPage.tsx
Normal file
112
src/frontend/src/apps/training/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Paper,
|
||||
Grid,
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from "@mui/material";
|
||||
import { Send as SendIcon } from "@mui/icons-material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { trainingService } from "../services/trainingService";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: expiringRecords = [], isLoading } = useQuery({
|
||||
queryKey: ["training", "expiring"],
|
||||
queryFn: () => trainingService.getExpiring(),
|
||||
});
|
||||
|
||||
const notifyMutation = useMutation({
|
||||
mutationFn: (id: number) => trainingService.sendNotification(id),
|
||||
onSuccess: () => {
|
||||
alert(t("training.notificationSent"));
|
||||
}
|
||||
});
|
||||
|
||||
const expiredCount = expiringRecords.filter((r: any) => r.stato === 2).length;
|
||||
const expiringCount = expiringRecords.filter((r: any) => r.stato === 1).length;
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "dataScadenza", headerName: t("training.expirationDate"), width: 120, valueFormatter: (params: any) => new Date(params.value).toLocaleDateString() },
|
||||
{ field: "course", headerName: t("training.course"), width: 200, valueGetter: (params: any) => params.row.articolo?.descrizione },
|
||||
{ field: "participant", headerName: t("training.participant"), width: 200, valueGetter: (params: any) => `${params.row.clienteContatto?.cognome} ${params.row.clienteContatto?.nome}` },
|
||||
{
|
||||
field: "stato",
|
||||
headerName: t("training.status"),
|
||||
width: 120,
|
||||
renderCell: (params: any) => (
|
||||
params.row.stato === 2
|
||||
? <Chip label={t("training.expired")} color="error" size="small" />
|
||||
: <Chip label={t("training.expiring")} color="warning" size="small" />
|
||||
)
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 100,
|
||||
renderCell: (params: any) => (
|
||||
<Tooltip title={t("training.sendNotification")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => notifyMutation.mutate(params.row.id)}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Typography variant="h4" mb={3}>{t("training.dashboard")}</Typography>
|
||||
|
||||
<Grid container spacing={3} mb={4}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{t("training.expired")}
|
||||
</Typography>
|
||||
<Typography variant="h3" color="error">
|
||||
{expiredCount}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{t("training.expiring")}
|
||||
</Typography>
|
||||
<Typography variant="h3" color="warning.main">
|
||||
{expiringCount}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography variant="h5" mb={2}>{t("training.expiring")}</Typography>
|
||||
<Paper sx={{ height: 400, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={expiringRecords}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
pageSizeOptions={[5, 10]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 5 } } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
46
src/frontend/src/apps/training/pages/DataExchangePage.tsx
Normal file
46
src/frontend/src/apps/training/pages/DataExchangePage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box, Paper, Typography, Button, Stack } from '@mui/material';
|
||||
import { CloudUpload as UploadIcon, Download as DownloadIcon } from '@mui/icons-material';
|
||||
|
||||
const DataExchangePage: React.FC = () => {
|
||||
// Placeholder - Fully implementing Excel import frontend needs file uploader and backend support
|
||||
// For now we setup the structure.
|
||||
|
||||
const handleImport = () => {
|
||||
alert("Import functionality to be implemented. Please use Import Template.");
|
||||
};
|
||||
|
||||
const handleExportElearning = () => {
|
||||
alert("Export functionality to be implemented.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>Import / Export Dati</Typography>
|
||||
|
||||
<Stack spacing={3} mt={4}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Importazione Storico (Excel)</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Carica un file Excel con lo storico delle formazioni. Assicurati di usare il template corretto.
|
||||
</Typography>
|
||||
<Button variant="contained" startIcon={<UploadIcon />} onClick={handleImport}>
|
||||
Carica File Excel
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Esportazione E-Learning</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Esporta l'anagrafica lavoratori in formato compatibile con piattaforme E-learning esterne (CSV/XLS).
|
||||
</Typography>
|
||||
<Button variant="outlined" startIcon={<DownloadIcon />} onClick={handleExportElearning}>
|
||||
Esporta Anagrafiche
|
||||
</Button>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataExchangePage;
|
||||
319
src/frontend/src/apps/training/pages/MatrixPage.tsx
Normal file
319
src/frontend/src/apps/training/pages/MatrixPage.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
UploadFile as UploadIcon,
|
||||
Description as DescriptionIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { trainingService } from "../services/trainingService";
|
||||
import { TrainingRecord, ClienteContatto, Articolo, TipoArticolo } from "../../../types";
|
||||
import { lookupService, articoliService, clientiService } from "../../../services/lookupService";
|
||||
|
||||
export default function MatrixPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<TrainingRecord>>({});
|
||||
const [selectedClient, setSelectedClient] = useState<number | null>(null);
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: records = [], isLoading } = useQuery({
|
||||
queryKey: ["training", "records"],
|
||||
queryFn: () => trainingService.getAll(),
|
||||
});
|
||||
|
||||
const { data: customers = [] } = useQuery({
|
||||
queryKey: ["lookup", "customers"],
|
||||
queryFn: () => lookupService.getClienti(),
|
||||
});
|
||||
|
||||
/* Removed unused trainingCategoryId logic */
|
||||
const { data: courses = [] } = useQuery({
|
||||
queryKey: ["articles", "training"],
|
||||
queryFn: () => articoliService.getAll({ tipo: TipoArticolo.Corso }),
|
||||
});
|
||||
|
||||
const { data: contacts = [] } = useQuery({
|
||||
queryKey: ["contacts", selectedClient],
|
||||
queryFn: () => selectedClient ? clientiService.getContatti(selectedClient) : [],
|
||||
enabled: !!selectedClient,
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: Partial<TrainingRecord>) => {
|
||||
const result = await trainingService.create(data);
|
||||
if (fileToUpload) {
|
||||
await trainingService.uploadCertificate(result.id, fileToUpload);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["training", "records"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: number; data: Partial<TrainingRecord> }) => {
|
||||
await trainingService.update(id, data);
|
||||
if (fileToUpload) {
|
||||
await trainingService.uploadCertificate(id, fileToUpload);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["training", "records"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => trainingService.delete(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["training", "records"] }),
|
||||
});
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingId(null);
|
||||
setFormData({});
|
||||
setSelectedClient(null);
|
||||
setFileToUpload(null);
|
||||
};
|
||||
|
||||
const handleEdit = (record: TrainingRecord) => {
|
||||
setFormData(record);
|
||||
setEditingId(record.id);
|
||||
|
||||
// Reverse lookup client from contact if possible?
|
||||
// Not directly available in record unless included.
|
||||
// Record has ClientContatto -> which has ClienteId (if included by backend).
|
||||
// Assuming backend includes ClienteContatto.
|
||||
if (record.clienteContatto) {
|
||||
setSelectedClient(record.clienteContatto.clienteId);
|
||||
}
|
||||
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (status?: number) => {
|
||||
if (status === 2) return <Chip label={t("training.expired")} color="error" size="small" />;
|
||||
if (status === 1) return <Chip label={t("training.expiring")} color="warning" size="small" />;
|
||||
return <Chip label={t("training.valid")} color="success" size="small" />;
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "dataEsecuzione",
|
||||
headerName: t("training.recordDate"),
|
||||
width: 110,
|
||||
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ""
|
||||
},
|
||||
{
|
||||
field: "dataScadenza",
|
||||
headerName: t("training.expirationDate"),
|
||||
width: 110,
|
||||
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ""
|
||||
},
|
||||
{
|
||||
field: "articolo",
|
||||
headerName: t("training.course"),
|
||||
width: 200,
|
||||
valueGetter: (params: any) => params.row.articolo?.descrizione || ""
|
||||
},
|
||||
{
|
||||
field: "clienteContatto",
|
||||
headerName: t("training.participant"),
|
||||
width: 200,
|
||||
valueGetter: (params: any) => {
|
||||
const c = params.row.clienteContatto;
|
||||
return c ? `${c.cognome} ${c.nome}` : "";
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "stato",
|
||||
headerName: t("training.status"),
|
||||
width: 120,
|
||||
renderCell: (params: any) => getStatusChip(params.row.stato)
|
||||
},
|
||||
{
|
||||
field: "attestatoUrl",
|
||||
headerName: t("training.certificate"),
|
||||
width: 100,
|
||||
renderCell: (params: any) => params.value ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
href={`/api/training/${params.row.id}/attestato`}
|
||||
target="_blank"
|
||||
>
|
||||
<DescriptionIcon />
|
||||
</IconButton>
|
||||
) : null
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
renderCell: (params: any) => (
|
||||
<Box>
|
||||
<IconButton size="small" onClick={() => handleEdit(params.row)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
if (confirm(t("common.deleteConfirm"))) {
|
||||
deleteMutation.mutate(params.row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">{t("training.matrix")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setOpenDialog(true)}
|
||||
>
|
||||
{t("training.newTraining")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={records}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
pageSizeOptions={[10, 25]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingId ? t("training.editTraining") : t("training.newTraining")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} mt={1}>
|
||||
<TextField
|
||||
select
|
||||
label={t("clients.businessName")} // Using client label?
|
||||
value={selectedClient || ""}
|
||||
onChange={(e) => setSelectedClient(Number(e.target.value))}
|
||||
fullWidth
|
||||
disabled={!!editingId} // Locked on edit if complex to change
|
||||
>
|
||||
{customers.map((c: any) => (
|
||||
<MenuItem key={c.id} value={c.id}>{c.ragioneSociale}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t("training.participant")}
|
||||
value={formData.clienteContattoId || ""}
|
||||
onChange={(e) => setFormData({ ...formData, clienteContattoId: Number(e.target.value) })}
|
||||
fullWidth
|
||||
required
|
||||
disabled={!selectedClient}
|
||||
>
|
||||
{contacts.map((c: ClienteContatto) => (
|
||||
<MenuItem key={c.id} value={c.id}>{c.cognome} {c.nome}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t("training.course")}
|
||||
value={formData.articoloId || ""}
|
||||
onChange={(e) => setFormData({ ...formData, articoloId: Number(e.target.value) })}
|
||||
fullWidth
|
||||
required
|
||||
>
|
||||
{courses.map((c: Articolo) => (
|
||||
<MenuItem key={c.id} value={c.id}>{c.descrizione}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
type="date"
|
||||
label={t("training.recordDate")}
|
||||
value={formData.dataEsecuzione ? formData.dataEsecuzione.split('T')[0] : ""}
|
||||
onChange={(e) => setFormData({ ...formData, dataEsecuzione: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
>
|
||||
{t("training.upload")}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
onChange={(e) => setFileToUpload(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</Button>
|
||||
{fileToUpload && <Typography variant="caption">{fileToUpload.name}</Typography>}
|
||||
|
||||
<TextField
|
||||
label={t("common.notes")}
|
||||
multiline
|
||||
rows={3}
|
||||
value={formData.note || ""}
|
||||
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
222
src/frontend/src/apps/training/pages/NotificationCenterPage.tsx
Normal file
222
src/frontend/src/apps/training/pages/NotificationCenterPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Chip,
|
||||
Stack,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as ApproveIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Send as SendIcon,
|
||||
Add as GenerateIcon
|
||||
} from '@mui/icons-material';
|
||||
import api from '../../../services/api';
|
||||
|
||||
const NotificationCenterPage: React.FC = () => {
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// Edit Dialog
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingNotif, setEditingNotif] = useState<any>(null);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await api.get('/training/notifications');
|
||||
setNotifications(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
await api.post('/training/notifications/generate?days=60');
|
||||
fetchNotifications();
|
||||
} catch (e) {
|
||||
alert('Errore generazione notifiche');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
try {
|
||||
await api.post(`/training/notifications/${id}/approve`);
|
||||
fetchNotifications();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
const res = await api.post('/training/notifications/send');
|
||||
alert(res.data.message);
|
||||
fetchNotifications();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Errore invio');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (notif: any) => {
|
||||
setEditingNotif({ ...notif });
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
try {
|
||||
await api.put(`/training/notifications/${editingNotif.id}`, editingNotif);
|
||||
setEditOpen(false);
|
||||
fetchNotifications();
|
||||
} catch (e) {
|
||||
alert('Errore salvataggio');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Cancellare questa notifica?')) return;
|
||||
try {
|
||||
await api.delete(`/training/notifications/${id}`);
|
||||
fetchNotifications();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, height: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4">Centro Notifiche</Typography>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
startIcon={<GenerateIcon />}
|
||||
onClick={handleGenerate}
|
||||
variant="outlined"
|
||||
disabled={generating}
|
||||
>
|
||||
Genera (60gg)
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<SendIcon />}
|
||||
onClick={handleSend}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
Invia Approvate
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Paper sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||
<List>
|
||||
{notifications.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary="Nessuna notifica in coda." />
|
||||
</ListItem>
|
||||
)}
|
||||
{notifications.map((notif) => (
|
||||
<React.Fragment key={notif.id}>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
{notif.subject}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={notif.status === 0 ? "In Attesa" : notif.status === 1 ? "Approvata" : notif.status === 2 ? "Inviata" : "Errore"}
|
||||
color={notif.status === 1 ? "success" : notif.status === 2 ? "default" : notif.status === 3 ? "error" : "warning"}
|
||||
size="small"
|
||||
/>
|
||||
{notif.errorMessage && <Chip label={notif.errorMessage} color="error" size="small" />}
|
||||
</Stack>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<Typography variant="body2" component="span">Desinatario: {notif.recipientEmail}</Typography>
|
||||
<br />
|
||||
<Typography variant="caption" component="span">Azienda: {notif.cliente?.ragioneSociale}</Typography>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
{notif.status === 0 && (
|
||||
<>
|
||||
<IconButton edge="end" onClick={() => handleApprove(notif.id)} color="success" title="Approva">
|
||||
<ApproveIcon />
|
||||
</IconButton>
|
||||
<IconButton edge="end" onClick={() => handleEdit(notif)} title="Modifica">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton edge="end" onClick={() => handleDelete(notif.id)} title="Elimina">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Modifica Notifica</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box pt={1}>
|
||||
<TextField
|
||||
label="Email Destinatario"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={editingNotif?.recipientEmail || ''}
|
||||
onChange={(e) => setEditingNotif({ ...editingNotif, recipientEmail: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Oggetto"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={editingNotif?.subject || ''}
|
||||
onChange={(e) => setEditingNotif({ ...editingNotif, subject: e.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Corpo (HTML)"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
multiline
|
||||
rows={10}
|
||||
value={editingNotif?.body || ''}
|
||||
onChange={(e) => setEditingNotif({ ...editingNotif, body: e.target.value })}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditOpen(false)}>Annulla</Button>
|
||||
<Button onClick={handleSaveEdit} variant="contained">Salva</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenterPage;
|
||||
262
src/frontend/src/apps/training/pages/RegistryPage.tsx
Normal file
262
src/frontend/src/apps/training/pages/RegistryPage.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { articoliService } from "../../../services/lookupService";
|
||||
import { categoryService } from "../../warehouse/services/warehouseService";
|
||||
import { Articolo, TipoArticolo } from "../../../types";
|
||||
import { MenuItem } from "@mui/material";
|
||||
|
||||
|
||||
export default function RegistryPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Articolo>>({
|
||||
attivo: true,
|
||||
tipo: TipoArticolo.Corso
|
||||
});
|
||||
|
||||
// 1. Fetch Request ALL Categories
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["warehouse-categories"],
|
||||
queryFn: () => categoryService.getAll(false),
|
||||
});
|
||||
|
||||
const trainingCategoryId = useMemo(() => {
|
||||
return categories.find((c: any) => c.code === "TRAIN")?.id;
|
||||
}, [categories]);
|
||||
|
||||
// Find all descendants of TRAIN
|
||||
const allowedCategories = useMemo(() => {
|
||||
if (!trainingCategoryId) return [];
|
||||
|
||||
const descendants: any[] = [];
|
||||
const queue = [trainingCategoryId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentId = queue.shift();
|
||||
const children = categories.filter((c: any) => c.parentCategoryId === parentId);
|
||||
children.forEach((c: any) => {
|
||||
descendants.push(c);
|
||||
queue.push(c.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Include TRAIN itself? Maybe better to force using subcategories if they exist,
|
||||
// but allowing TRAIN is flexible.
|
||||
const root = categories.find((c: any) => c.id === trainingCategoryId);
|
||||
return root ? [root, ...descendants] : descendants;
|
||||
}, [categories, trainingCategoryId]);
|
||||
|
||||
|
||||
// 2. Fetch Articles filtered by TipoArticolo.Corso (ignore category filter for list to show all)
|
||||
const { data: articles = [], isLoading } = useQuery({
|
||||
queryKey: ["articles", "training"],
|
||||
queryFn: () => {
|
||||
// We explicitly want ALL courses, regardless of subcategory
|
||||
const params: any = { tipo: TipoArticolo.Corso };
|
||||
return articoliService.getAll(params);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<Articolo>) => articoliService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["articles", "training"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<Articolo> }) =>
|
||||
articoliService.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["articles", "training"] });
|
||||
handleCloseDialog();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => articoliService.delete(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["articles", "training"] }),
|
||||
});
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setEditingId(null);
|
||||
setFormData({ attivo: true, tipo: TipoArticolo.Corso });
|
||||
};
|
||||
|
||||
const handleEdit = (article: Articolo) => {
|
||||
setFormData(article);
|
||||
setEditingId(article.id);
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!trainingCategoryId) {
|
||||
// Warning but proceed with just Type? User said "linked to warehouse articles but with specific classification".
|
||||
// Classification (Type) is key. Category is secondary but described in plan.
|
||||
// If "TRAIN" category exists we use it, otherwise we rely on Tipo.
|
||||
// But the previous code had an alert.
|
||||
// I'll keep the alert if strict, or maybe auto-create category?
|
||||
// Let's keep strictness on Category if plan required it.
|
||||
}
|
||||
|
||||
if (!trainingCategoryId) {
|
||||
alert("Errore: Categoria 'Formazione' non trovata. Contattare l'amministratore.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
categoriaId: formData.categoriaId || trainingCategoryId, // Use selected or default to TRAIN
|
||||
tipoMaterialeId: 1, // Default Material Type ID
|
||||
unitaMisura: "H", // Hours
|
||||
tipo: TipoArticolo.Corso, // Force Type
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, data: dataToSave });
|
||||
} else {
|
||||
createMutation.mutate(dataToSave);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "codice", headerName: t("training.course"), width: 120 },
|
||||
{ field: "descrizione", headerName: t("common.description"), flex: 1 },
|
||||
{ field: "giorniValidita", headerName: t("training.validityDays"), width: 150 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<IconButton size="small" onClick={() => handleEdit(params.row)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
if (confirm(t("common.deleteConfirm"))) {
|
||||
deleteMutation.mutate(params.row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!trainingCategoryId && !isLoading && categories.length > 0) {
|
||||
return (
|
||||
<Box p={3}>
|
||||
<Typography color="error">
|
||||
Categoria "Formazione" (TRAIN) non trovata. Eseguire il seed del database.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h4">{t("training.registry")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setOpenDialog(true)}
|
||||
>
|
||||
{t("training.newTraining")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={articles}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
pageSizeOptions={[10, 25]}
|
||||
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingId ? t("training.editCourse") : t("training.newTraining")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box display="flex" flexDirection="column" gap={2} mt={1}>
|
||||
<TextField
|
||||
label={t("common.code")}
|
||||
fullWidth
|
||||
value={editingId ? formData.codice : t("clients.generatedOnSave")}
|
||||
disabled
|
||||
InputProps={{ readOnly: true }}
|
||||
/>
|
||||
<TextField
|
||||
label={t("common.description")}
|
||||
value={formData.descrizione || ""}
|
||||
onChange={(e) => setFormData({ ...formData, descrizione: e.target.value })}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t("common.category")}
|
||||
value={formData.categoriaId || trainingCategoryId || ""}
|
||||
onChange={(e) => setFormData({ ...formData, categoriaId: Number(e.target.value) })}
|
||||
fullWidth
|
||||
>
|
||||
{allowedCategories.map((c: any) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name} ({c.code})
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label={t("training.validityDays")}
|
||||
type="number"
|
||||
value={formData.giorniValidita || ""}
|
||||
onChange={(e) => setFormData({ ...formData, giorniValidita: parseInt(e.target.value) || 0 })}
|
||||
fullWidth
|
||||
helperText="Giorni dopo i quali il corso scade"
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t("common.cancel")}</Button>
|
||||
<Button variant="contained" onClick={handleSubmit}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
115
src/frontend/src/apps/training/pages/TrainingDeadlinesPage.tsx
Normal file
115
src/frontend/src/apps/training/pages/TrainingDeadlinesPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
Stack
|
||||
} from '@mui/material';
|
||||
import { DataGrid, GridColDef, GridToolbar, GridRenderCellParams } from '@mui/x-data-grid';
|
||||
import {
|
||||
FileDownload as ExportIcon,
|
||||
CheckCircle as ValidIcon,
|
||||
Warning as ExpiringIcon,
|
||||
Error as ExpiredIcon
|
||||
} from '@mui/icons-material';
|
||||
import api from '../../../services/api';
|
||||
|
||||
// Types
|
||||
interface TrainingRecord {
|
||||
id: number;
|
||||
clienteContatto: {
|
||||
id: number;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
cliente: {
|
||||
id: number;
|
||||
ragioneSociale: string;
|
||||
}
|
||||
};
|
||||
articolo: {
|
||||
id: number;
|
||||
descrizione: string;
|
||||
};
|
||||
dataEsecuzione: string;
|
||||
dataScadenza: string;
|
||||
stato: number;
|
||||
}
|
||||
|
||||
const TrainingDeadlinesPage: React.FC = () => {
|
||||
const [rows, setRows] = useState<TrainingRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchDeadlines = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/training');
|
||||
setRows(response.data || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching deadlines", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeadlines();
|
||||
}, []);
|
||||
|
||||
const getStatusChip = (params: GridRenderCellParams<any, number>) => {
|
||||
const status = params.value;
|
||||
if (status === 2) return <Chip icon={<ExpiredIcon />} label="Scaduto" color="error" size="small" />;
|
||||
if (status === 1) return <Chip icon={<ExpiringIcon />} label="In Scadenza" color="warning" size="small" />;
|
||||
return <Chip icon={<ValidIcon />} label="Valido" color="success" size="small" />;
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: 'azienda', headerName: 'Azienda', width: 200, valueGetter: (params: any) => params.row.clienteContatto?.cliente?.ragioneSociale },
|
||||
{ field: 'lavoratore', headerName: 'Lavoratore', width: 200, valueGetter: (params: any) => `${params.row.clienteContatto?.nome} ${params.row.clienteContatto?.cognome}` },
|
||||
{ field: 'corso', headerName: 'Corso', width: 250, valueGetter: (params: any) => params.row.articolo?.descrizione },
|
||||
{ field: 'dataEsecuzione', headerName: 'Data Esecuzione', width: 130, type: 'date', valueGetter: (params: any) => new Date(params.row.dataEsecuzione) },
|
||||
{ field: 'dataScadenza', headerName: 'Scadenza', width: 130, type: 'date', valueGetter: (params: any) => params.row.dataScadenza ? new Date(params.row.dataScadenza) : null },
|
||||
{
|
||||
field: 'stato',
|
||||
headerName: 'Stato',
|
||||
width: 150,
|
||||
renderCell: getStatusChip
|
||||
},
|
||||
];
|
||||
|
||||
const handleExport = () => {
|
||||
alert("Export functionality to be implemented (Backend API ready but needs explicit call)");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4">Scadenzario Formazione</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ExportIcon />}
|
||||
onClick={handleExport}
|
||||
>
|
||||
Esporta Excel
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Paper sx={{ flexGrow: 1, p: 2 }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
initialState={{
|
||||
sorting: {
|
||||
sortModel: [{ field: 'dataScadenza', sort: 'asc' }],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrainingDeadlinesPage;
|
||||
118
src/frontend/src/apps/training/pages/WorkersRegistryPage.tsx
Normal file
118
src/frontend/src/apps/training/pages/WorkersRegistryPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Chip,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import { DataGrid, GridColDef, GridToolbar } from '@mui/x-data-grid';
|
||||
import api from '../../../services/api';
|
||||
|
||||
const WorkersRegistryPage: React.FC = () => {
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchWorkers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch all trainings and grouping by worker client side fallback
|
||||
const response = await api.get('/training');
|
||||
const trainings = response.data || [];
|
||||
|
||||
const workersMap = new Map();
|
||||
|
||||
trainings.forEach((t: any) => {
|
||||
const workerId = t.clienteContattoId;
|
||||
const contact = t.clienteContatto; // Ensure this exists
|
||||
if (!contact) return;
|
||||
|
||||
if (!workersMap.has(workerId)) {
|
||||
workersMap.set(workerId, {
|
||||
id: workerId,
|
||||
nome: contact.nome,
|
||||
cognome: contact.cognome,
|
||||
azienda: contact.cliente?.ragioneSociale,
|
||||
ruolo: contact.ruolo,
|
||||
trainings: [],
|
||||
scaduti: 0,
|
||||
inScadenza: 0
|
||||
});
|
||||
}
|
||||
const w = workersMap.get(workerId);
|
||||
w.trainings.push(t);
|
||||
|
||||
const status = t.stato; // 0=Valid, 1=Expiring, 2=Expired (Assuming)
|
||||
// Wait, I defined helper in backend but not returned in JSON unless mapped?
|
||||
// I should calculate client side to satisfy linter or ensure backend sends it.
|
||||
// Backend has [NotMapped] so it is NOT sent by default.
|
||||
// I need to enable it or calculate it.
|
||||
// Client side calc:
|
||||
const today = new Date();
|
||||
const expiry = t.dataScadenza ? new Date(t.dataScadenza) : null;
|
||||
let calculatedStatus = 0;
|
||||
if (expiry) {
|
||||
const diffTime = expiry.getTime() - today.getTime();
|
||||
const diffDays = diffTime / (1000 * 3600 * 24);
|
||||
if (diffDays < 0) calculatedStatus = 2;
|
||||
else if (diffDays <= 30) calculatedStatus = 1;
|
||||
}
|
||||
|
||||
if (calculatedStatus === 2) w.scaduti++;
|
||||
if (calculatedStatus === 1) w.inScadenza++;
|
||||
});
|
||||
|
||||
setRows(Array.from(workersMap.values()));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching workers", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkers();
|
||||
}, []);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'avatar',
|
||||
headerName: '',
|
||||
width: 50,
|
||||
renderCell: (params) => <Avatar>{params.row.nome?.charAt(0)}{params.row.cognome?.charAt(0)}</Avatar>
|
||||
},
|
||||
{ field: 'nome', headerName: 'Nome', width: 150 },
|
||||
{ field: 'cognome', headerName: 'Cognome', width: 150 },
|
||||
{ field: 'azienda', headerName: 'Azienda', width: 200 },
|
||||
{ field: 'ruolo', headerName: 'Ruolo', width: 150 },
|
||||
{
|
||||
field: 'status',
|
||||
headerName: 'Stato Formativo',
|
||||
width: 200,
|
||||
renderCell: (params) => {
|
||||
const { scaduti, inScadenza } = params.row;
|
||||
if (scaduti > 0) return <Chip label={`${scaduti} Scaduti`} color="error" size="small" />;
|
||||
if (inScadenza > 0) return <Chip label={`${inScadenza} In Scadenza`} color="warning" size="small" />;
|
||||
return <Chip label="Regolare" color="success" size="small" />;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h4" mb={3}>Registro Lavoratori</Typography>
|
||||
|
||||
<Paper sx={{ flexGrow: 1, p: 2 }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkersRegistryPage;
|
||||
27
src/frontend/src/apps/training/routes.tsx
Normal file
27
src/frontend/src/apps/training/routes.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import TrainingLayout from "./components/TrainingLayout";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
import RegistryPage from "./pages/RegistryPage";
|
||||
import MatrixPage from "./pages/MatrixPage";
|
||||
import TrainingDeadlinesPage from "./pages/TrainingDeadlinesPage";
|
||||
import NotificationCenterPage from "./pages/NotificationCenterPage";
|
||||
import DataExchangePage from "./pages/DataExchangePage";
|
||||
|
||||
import WorkersRegistryPage from "./pages/WorkersRegistryPage";
|
||||
|
||||
export default function TrainingRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<TrainingLayout />}>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="registry" element={<RegistryPage />} />
|
||||
<Route path="matrix" element={<MatrixPage />} />
|
||||
<Route path="deadlines" element={<TrainingDeadlinesPage />} />
|
||||
<Route path="notifications" element={<NotificationCenterPage />} />
|
||||
<Route path="data-exchange" element={<DataExchangePage />} />
|
||||
<Route path="workers" element={<WorkersRegistryPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
33
src/frontend/src/apps/training/services/trainingService.ts
Normal file
33
src/frontend/src/apps/training/services/trainingService.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import api from "../../../services/api";
|
||||
import { TrainingRecord } from "../../../types";
|
||||
|
||||
export const trainingService = {
|
||||
getAll: async (params?: { start?: string; end?: string; customerId?: number; courseId?: number }) => {
|
||||
const { data } = await api.get<TrainingRecord[]>("/training", { params });
|
||||
return data;
|
||||
},
|
||||
create: async (record: Partial<TrainingRecord>) => {
|
||||
const { data } = await api.post<TrainingRecord>("/training", record);
|
||||
return data;
|
||||
},
|
||||
update: async (id: number, record: Partial<TrainingRecord>) => {
|
||||
await api.put(`/training/${id}`, record);
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
await api.delete(`/training/${id}`);
|
||||
},
|
||||
uploadCertificate: async (id: number, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await api.post(`/training/${id}/attestato`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
});
|
||||
},
|
||||
getExpiring: async () => {
|
||||
const { data } = await api.get<TrainingRecord[]>("/training/expiring");
|
||||
return data;
|
||||
},
|
||||
sendNotification: async (id: number) => {
|
||||
await api.post(`/training/${id}/notify`);
|
||||
}
|
||||
};
|
||||
@@ -1,99 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Box, Tabs, Tab, Paper, Typography, Breadcrumbs, Link } from "@mui/material";
|
||||
import {
|
||||
Dashboard as DashboardIcon,
|
||||
Inventory as ArticleIcon,
|
||||
Place as LocationIcon,
|
||||
SwapHoriz as MovementIcon,
|
||||
Assessment as StockIcon,
|
||||
FactCheck as InventoryIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Dashboard", path: "/warehouse", icon: <DashboardIcon fontSize="small" /> },
|
||||
{ label: "Articoli", path: "/warehouse/articles", icon: <ArticleIcon fontSize="small" /> },
|
||||
{ label: "Magazzini", path: "/warehouse/locations", icon: <LocationIcon fontSize="small" /> },
|
||||
{ label: "Movimenti", path: "/warehouse/movements", icon: <MovementIcon fontSize="small" /> },
|
||||
{ label: "Giacenze", path: "/warehouse/stock", icon: <StockIcon fontSize="small" /> },
|
||||
{ label: "Inventario", path: "/warehouse/inventory", icon: <InventoryIcon fontSize="small" /> },
|
||||
];
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
export default function WarehouseLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [value, setValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Find the matching tab based on current path
|
||||
const index = navItems.findIndex((item) => {
|
||||
if (item.path === "/warehouse") {
|
||||
return location.pathname === "/warehouse";
|
||||
}
|
||||
return location.pathname.startsWith(item.path);
|
||||
});
|
||||
|
||||
if (index !== -1) {
|
||||
setValue(index);
|
||||
} else {
|
||||
// If no match (e.g. sub-pages), keep the closest parent or default
|
||||
// Logic could be improved here but keeping it simple for now
|
||||
if (location.pathname.includes("articles")) setValue(1);
|
||||
else if (location.pathname.includes("locations")) setValue(2);
|
||||
else if (location.pathname.includes("movements")) setValue(3);
|
||||
else if (location.pathname.includes("stock")) setValue(4);
|
||||
else if (location.pathname.includes("inventory")) setValue(5);
|
||||
else setValue(0);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
navigate(navItems[newValue].path);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", gap: 2 }}>
|
||||
{/* Header & Navigation */}
|
||||
<Paper sx={{ px: 2, pt: 2, pb: 0 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" component="h1" fontWeight="bold" gutterBottom>
|
||||
Gestione Magazzino
|
||||
</Typography>
|
||||
<Breadcrumbs aria-label="breadcrumb">
|
||||
<Link underline="hover" color="inherit" href="/">
|
||||
Home
|
||||
</Link>
|
||||
<Typography color="text.primary">Magazzino</Typography>
|
||||
{navItems[value]?.label !== "Dashboard" && (
|
||||
<Typography color="text.primary">{navItems[value]?.label}</Typography>
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
</Box>
|
||||
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="warehouse navigation tabs"
|
||||
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||
>
|
||||
{navItems.map((item, index) => (
|
||||
<Tab
|
||||
key={item.path}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
iconPosition="start"
|
||||
id={`warehouse-tab-${index}`}
|
||||
aria-controls={`warehouse-tabpanel-${index}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflow: "auto", p: 3 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user