diff --git a/CLAUDE.md b/CLAUDE.md
index 97f9762..123dfbc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,12 +46,52 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
## Quick Start - Session Recovery
-**Ultima sessione:** 29 Novembre 2025
+**Ultima sessione:** 29 Novembre 2025 (sera)
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
**Lavoro completato nell'ultima sessione:**
+- **NUOVA FEATURE: Sistema Moduli Applicativi** - COMPLETATO (continuazione)
+ - **Obiettivo:** Sistema di modularizzazione per gestire licenze, abbonamenti e funzionalità dinamiche
+ - **Backend implementato:**
+ - `AppModule.cs` - Entity per definizione moduli (code, name, description, icon, basePrice, dependencies, etc.)
+ - `ModuleSubscription.cs` - Entity per stato abbonamento (isEnabled, subscriptionType, startDate, endDate, autoRenew)
+ - `ModuleService.cs` - Logica business (enable/disable, check dipendenze, gestione scadenze, cache)
+ - `ModulesController.cs` - API REST complete con DTOs
+ - Tabelle SQLite create manualmente (AppModules, ModuleSubscriptions)
+ - Seed automatico 5 moduli: warehouse, purchases, sales, production, quality
+ - **Frontend implementato:**
+ - `module.ts` - Types TypeScript (ModuleDto, SubscriptionDto, enums, helpers)
+ - `moduleService.ts` - API calls
+ - `ModuleContext.tsx` - React Context con hooks (useModules, useModuleEnabled, useActiveModules)
+ - `ModuleGuard.tsx` - Componente per proteggere route
+ - `ModulesAdminPage.tsx` - Pagina amministrazione moduli con cards, toggle, dettagli subscription
+ - `ModulePurchasePage.tsx` - Pagina acquisto/attivazione modulo con selezione piano
+ - **Integrazione:**
+ - `App.tsx` - ModuleProvider wrappa l'app, route /modules e /modules/purchase/:code
+ - `Layout.tsx` - Voce menu "Moduli" aggiunta
+ - **API Endpoints:**
+ - `GET /api/modules` - Lista tutti i moduli
+ - `GET /api/modules/active` - Solo moduli attivi
+ - `GET /api/modules/{code}` - Dettaglio modulo
+ - `GET /api/modules/{code}/enabled` - Verifica stato
+ - `PUT /api/modules/{code}/enable` - Attiva modulo
+ - `PUT /api/modules/{code}/disable` - Disattiva modulo
+ - `GET /api/modules/subscriptions` - Lista subscription
+ - `PUT /api/modules/{code}/subscription` - Aggiorna subscription
+ - `POST /api/modules/{code}/subscription/renew` - Rinnova
+ - `GET /api/modules/expiring` - Moduli in scadenza
+ - **Funzionalità:**
+ - Gestione dipendenze tra moduli (es. purchases richiede warehouse)
+ - Blocco disattivazione se altri moduli dipendono
+ - Abbonamenti mensili/annuali con date scadenza
+ - Auto-rinnovo opzionale
+ - Cache con invalidazione automatica
+ - Alert moduli in scadenza
+
+**Lavoro completato nelle sessioni precedenti (29 Novembre 2025 mattina):**
+
- **NUOVA FEATURE: Sistema Pannelli Drag-and-Drop con Sidebar Ridimensionabili** - COMPLETATO
- **Obiettivo:** I pannelli del report designer devono poter essere trascinati tra sidebar sinistra e destra, con ridimensionamento orizzontale a livello sidebar
- **Architettura implementata:**
@@ -274,15 +314,23 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
- **PDF Generator:** `/src/Apollinare.API/Services/Reports/ReportGeneratorService.cs`
- **Page Navigator:** `/frontend/src/components/reportEditor/PageNavigator.tsx`
-**Prossimi task prioritari (Report System):**
+**Prossimi task prioritari:**
-1. [x] ~~**CRITICO: Posizionamento assoluto PDF**~~ - COMPLETATO
-2. [x] ~~Implementare caricamento immagini reali~~ - COMPLETATO
-3. [x] ~~**FIX: Rotazione oggetti nel PDF**~~ - COMPLETATO
-4. [x] ~~**Gestione Multi-Pagina**~~ - COMPLETATO
-5. [ ] Aggiungere rendering tabelle dinamiche per collection
-6. [ ] Gestire sezioni header/footer ripetute su ogni pagina
-7. [ ] UI per relazioni tra dataset multipli
+**MODULI BUSINESS (PRIORITÀ ALTA):**
+
+1. [ ] **Implementare modulo Magazzino (warehouse)** - Base per tutti gli altri
+2. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino
+3. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino
+4. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino
+5. [ ] **Implementare modulo Qualità (quality)** - Indipendente
+
+**Report System (completamento):**
+
+- [ ] Aggiungere rendering tabelle dinamiche per collection
+- [ ] Gestire sezioni header/footer ripetute su ogni pagina
+- [ ] UI per relazioni tra dataset multipli
+
+**NOTA:** Vedere sezione "Prossimi Passi: Implementazione Moduli Business" per dettagli architetturali e principi di personalizzazione.
**Comandi utili (usa il Makefile!):**
@@ -1345,3 +1393,465 @@ Migration già applicata per SQLite.
```
Menu aggiunto in `Layout.tsx` sotto "Report" con icona PrintIcon.
+
+---
+
+## Sistema Moduli - Architettura e Implementazione
+
+### Overview
+
+Sistema di modularizzazione dell'applicazione per gestione licenze, abbonamenti e funzionalità dinamiche. Ogni modulo rappresenta una sezione business completa (es. Magazzino, Acquisti, Vendite) che può essere attivata/disattivata per cliente.
+
+### Requisiti Funzionali
+
+**Moduli previsti:**
+
+- `warehouse` - Magazzino (gestione inventario, movimenti, giacenze)
+- `purchases` - Acquisti (ordini fornitori, DDT entrata, fatture passive)
+- `sales` - Vendite (ordini clienti, DDT uscita, fatture attive)
+- `production` - Produzione (cicli produttivi, distinte base, MRP)
+- `quality` - Qualità (controlli, non conformità, certificazioni)
+
+**Funzionalità Core (sempre attive):**
+
+- Report e template PDF
+- Gestione utenti e autenticazione (futuro)
+- Dashboard e navigazione base
+- Impostazioni sistema
+
+### Comportamento UI
+
+1. **Modulo attivo:** Menu visibile, route accessibili, funzionalità complete
+2. **Modulo disattivato:**
+ - Menu nascosto
+ - Route redirect a pagina `/modules/purchase/{moduleCode}`
+ - Le funzioni di altri moduli che usavano dati del modulo disattivato continuano a funzionare (dati storici preservati)
+
+### Modello Dati
+
+```
+AppModule (tabella moduli disponibili)
+├── Id: int (PK)
+├── Code: string (unique, es. "warehouse")
+├── Name: string (es. "Magazzino")
+├── Description: string
+├── Icon: string (nome icona MUI)
+├── BasePrice: decimal (prezzo base annuale)
+├── MonthlyMultiplier: decimal (moltiplicatore per abbonamento mensile, es. 1.2)
+├── SortOrder: int (ordine nel menu)
+├── IsCore: bool (true = sempre attivo, non disattivabile)
+├── Dependencies: string[] (codici moduli prerequisiti)
+├── CreatedAt: DateTime
+├── UpdatedAt: DateTime
+
+ModuleSubscription (stato abbonamento per istanza)
+├── Id: int (PK)
+├── ModuleId: int (FK → AppModule)
+├── IsEnabled: bool
+├── SubscriptionType: enum (None, Monthly, Annual)
+├── StartDate: DateTime?
+├── EndDate: DateTime?
+├── AutoRenew: bool
+├── CreatedAt: DateTime
+├── UpdatedAt: DateTime
+```
+
+### API Endpoints
+
+```
+# Moduli (lettura per tutti, scrittura solo admin)
+GET /api/modules # Lista tutti i moduli con stato subscription
+GET /api/modules/{code} # Dettaglio singolo modulo
+GET /api/modules/active # Solo moduli attivi (per menu)
+PUT /api/modules/{code}/enable # Attiva modulo
+PUT /api/modules/{code}/disable # Disattiva modulo
+
+# Subscriptions (admin only)
+GET /api/modules/subscriptions # Lista tutte le subscription
+PUT /api/modules/{code}/subscription # Aggiorna subscription (tipo, date)
+POST /api/modules/{code}/subscription/renew # Rinnova abbonamento
+```
+
+### Frontend Architecture
+
+**Context e State:**
+
+- `ModuleContext.tsx` - React Context con stato moduli globale
+- `useModules()` - Hook per accesso a lista moduli
+- `useModuleEnabled(code)` - Hook per check singolo modulo
+- `useActiveModules()` - Hook per moduli attivi (per menu)
+
+**Componenti:**
+
+- `ModuleGuard.tsx` - HOC/wrapper che verifica accesso a route
+- `ModulePurchasePage.tsx` - Pagina acquisto/attivazione modulo
+- `ModulesAdminPage.tsx` - Pannello admin gestione moduli
+
+**Integrazione Menu (Layout.tsx):**
+
+```typescript
+// Filtra voci menu in base a moduli attivi
+const menuItems = allMenuItems.filter(
+ (item) => !item.moduleCode || activeModuleCodes.includes(item.moduleCode),
+);
+```
+
+**Routing (App.tsx):**
+
+```typescript
+// Route protette da modulo
+
+
+
+ }
+/>
+
+// Pagina acquisto modulo
+} />
+```
+
+### Logica Backend
+
+**ModuleService:**
+
+```csharp
+public class ModuleService
+{
+ // Verifica se modulo è attivo (usato da altri servizi)
+ public async Task IsModuleEnabledAsync(string code);
+
+ // Verifica subscription valida (non scaduta)
+ public async Task HasValidSubscriptionAsync(string code);
+
+ // Attiva modulo (crea/aggiorna subscription)
+ public async Task EnableModuleAsync(string code, SubscriptionType type, DateTime? endDate);
+
+ // Disattiva modulo (preserva dati)
+ public async Task DisableModuleAsync(string code);
+
+ // Job schedulato: controlla scadenze e disattiva moduli scaduti
+ public async Task CheckExpiredSubscriptionsAsync();
+}
+```
+
+### Principi di Design
+
+1. **Riutilizzo codice:** I moduli possono importare servizi/componenti da altri moduli
+2. **Dati persistenti:** Disattivare un modulo non elimina i dati, solo nasconde l'accesso
+3. **Dipendenze:** Un modulo può richiedere altri moduli (es. Produzione richiede Magazzino)
+4. **Core inattaccabile:** Report, utenti, dashboard non sono disattivabili
+5. **Check lato backend:** La verifica modulo avviene sempre sul server, mai solo frontend
+6. **Cache:** Stato moduli cachato con invalidazione su modifica
+
+### Struttura File
+
+```
+src/Apollinare.Domain/Entities/
+├── AppModule.cs
+└── ModuleSubscription.cs
+
+src/Apollinare.API/
+├── Controllers/
+│ └── ModulesController.cs
+├── Services/
+│ └── ModuleService.cs
+└── DTOs/
+ └── ModuleDtos.cs
+
+frontend/src/
+├── contexts/
+│ └── ModuleContext.tsx
+├── components/
+│ └── ModuleGuard.tsx
+├── pages/
+│ ├── ModulesAdminPage.tsx
+│ └── ModulePurchasePage.tsx
+├── services/
+│ └── moduleService.ts
+└── types/
+ └── module.ts
+```
+
+### Checklist Implementazione
+
+**Backend:**
+
+- [x] Entity `AppModule`
+- [x] Entity `ModuleSubscription`
+- [x] `ModuleService` con logica business
+- [x] `ModulesController` con tutti gli endpoint
+- [x] DbSet e migration EF Core (tabelle create manualmente in SQLite)
+- [x] Seed dati iniziali (5 moduli)
+- [ ] Job controllo scadenze (opzionale - futuro)
+
+**Frontend:**
+
+- [x] Types `module.ts`
+- [x] Service `moduleService.ts`
+- [x] Context `ModuleContext.tsx`
+- [x] Component `ModuleGuard.tsx`
+- [x] Page `ModulesAdminPage.tsx`
+- [x] Page `ModulePurchasePage.tsx`
+- [x] Integrazione `Layout.tsx` per menu dinamico
+- [x] Route protection in `App.tsx`
+
+**Testing:**
+
+- [x] API CRUD moduli
+- [x] API subscription
+- [x] Redirect su modulo disattivato
+- [x] Menu filtrato correttamente
+- [ ] Scadenza subscription (da testare con date reali)
+
+---
+
+## Prossimi Passi: Implementazione Moduli Business
+
+**PRIORITÀ ALTA - Da implementare:**
+
+I moduli sono stati definiti a livello infrastrutturale (sistema licenze/abbonamenti). Ora bisogna implementare le funzionalità reali di ogni modulo.
+
+### Ordine di Implementazione Consigliato
+
+1. **Magazzino (warehouse)** - Base per tutti gli altri moduli
+2. **Acquisti (purchases)** - Dipende da Magazzino
+3. **Vendite (sales)** - Dipende da Magazzino
+4. **Produzione (production)** - Dipende da Magazzino
+5. **Qualità (quality)** - Indipendente
+
+### Architettura Modulare - Principi di Personalizzazione
+
+**IMPORTANTE:** Ogni modulo deve essere facilmente personalizzabile da codice per adattarsi a esigenze specifiche del cliente.
+
+**Struttura consigliata per ogni modulo:**
+
+```
+src/Apollinare.API/
+├── Modules/
+│ ├── Warehouse/
+│ │ ├── Controllers/
+│ │ │ └── WarehouseController.cs
+│ │ ├── Services/
+│ │ │ ├── IWarehouseService.cs # Interfaccia per DI/mock
+│ │ │ └── WarehouseService.cs
+│ │ ├── DTOs/
+│ │ │ └── WarehouseDtos.cs
+│ │ └── Configuration/
+│ │ └── WarehouseConfig.cs # Configurazione modulo
+│ ├── Purchases/
+│ │ └── ...
+│ └── Sales/
+│ └── ...
+
+src/Apollinare.Domain/
+├── Entities/
+│ ├── Warehouse/
+│ │ ├── Article.cs
+│ │ ├── StockMovement.cs
+│ │ └── Warehouse.cs
+│ ├── Purchases/
+│ │ ├── PurchaseOrder.cs
+│ │ └── Supplier.cs
+│ └── Sales/
+│ ├── SalesOrder.cs
+│ └── Customer.cs
+
+frontend/src/
+├── modules/
+│ ├── warehouse/
+│ │ ├── pages/
+│ │ │ ├── ArticlesPage.tsx
+│ │ │ ├── StockMovementsPage.tsx
+│ │ │ └── InventoryPage.tsx
+│ │ ├── components/
+│ │ │ └── ArticleForm.tsx
+│ │ ├── services/
+│ │ │ └── warehouseService.ts
+│ │ ├── types/
+│ │ │ └── warehouse.ts
+│ │ ├── hooks/
+│ │ │ └── useWarehouse.ts
+│ │ └── routes.tsx # Route del modulo
+│ ├── purchases/
+│ │ └── ...
+│ └── sales/
+│ └── ...
+```
+
+**Pattern di Personalizzazione:**
+
+1. **Interfacce per Services:** Usare sempre interfacce (`IWarehouseService`) per permettere override tramite DI
+2. **Configurazione esterna:** Parametri configurabili in `appsettings.json` o database
+3. **Hook points:** Esporre eventi/callback per estensioni custom
+4. **Component composition:** Componenti React piccoli e componibili
+5. **Feature flags:** Sotto-funzionalità attivabili/disattivabili per modulo
+
+**Esempio configurazione modulo:**
+
+```csharp
+// WarehouseConfig.cs
+public class WarehouseConfig
+{
+ public bool EnableBarcodeScanning { get; set; } = true;
+ public bool EnableMultiWarehouse { get; set; } = false;
+ public bool EnableSerialTracking { get; set; } = false;
+ public bool EnableBatchTracking { get; set; } = false;
+ public int LowStockThreshold { get; set; } = 10;
+ public List CustomFields { get; set; } = new();
+}
+```
+
+**Esempio hook point per estensioni:**
+
+```csharp
+// IWarehouseService.cs
+public interface IWarehouseService
+{
+ // Metodi standard
+ Task GetArticleAsync(int id);
+ Task CreateArticleAsync(ArticleDto dto);
+
+ // Hook points per customizzazione
+ event Func? OnArticleCreated;
+ event Func>? OnBeforeStockMovement;
+ event Func? OnAfterStockMovement;
+}
+```
+
+### Dettaglio Moduli da Implementare
+
+#### 1. Magazzino (warehouse)
+
+**Funzionalità:**
+
+- Anagrafica articoli (CRUD, categorie, immagini)
+- Gestione magazzini multipli (opzionale)
+- Movimenti di magazzino (carico, scarico, trasferimento)
+- Giacenze e disponibilità
+- Inventario e rettifiche
+- Alert scorte minime
+- Codici a barre / QR code (opzionale)
+- Tracciabilità lotti/serial (opzionale)
+
+**Entità principali:**
+
+- `Article` - Articolo/prodotto
+- `ArticleCategory` - Categorie articoli
+- `Warehouse` - Magazzino fisico
+- `StockMovement` - Movimento di magazzino
+- `StockLevel` - Giacenza per articolo/magazzino
+
+#### 2. Acquisti (purchases)
+
+**Funzionalità:**
+
+- Anagrafica fornitori
+- Richieste di acquisto (RDA)
+- Ordini a fornitore (OdA)
+- DDT entrata (ricezione merce)
+- Fatture passive
+- Listini fornitore
+- Storico prezzi
+
+**Entità principali:**
+
+- `Supplier` - Fornitore
+- `PurchaseRequest` - Richiesta d'acquisto
+- `PurchaseOrder` - Ordine a fornitore
+- `PurchaseOrderLine` - Riga ordine
+- `GoodsReceipt` - DDT entrata
+- `SupplierInvoice` - Fattura passiva
+
+#### 3. Vendite (sales)
+
+**Funzionalità:**
+
+- Anagrafica clienti
+- Preventivi
+- Ordini cliente
+- DDT uscita
+- Fatture attive
+- Listini cliente
+- Provvigioni agenti (opzionale)
+
+**Entità principali:**
+
+- `Customer` - Cliente
+- `Quote` - Preventivo
+- `SalesOrder` - Ordine cliente
+- `SalesOrderLine` - Riga ordine
+- `DeliveryNote` - DDT uscita
+- `Invoice` - Fattura attiva
+
+#### 4. Produzione (production)
+
+**Funzionalità:**
+
+- Distinte base (BOM)
+- Cicli di lavorazione
+- Ordini di produzione
+- Avanzamento produzione
+- Pianificazione (MRP semplificato)
+- Costi di produzione
+
+**Entità principali:**
+
+- `BillOfMaterials` - Distinta base
+- `BomComponent` - Componente distinta
+- `WorkCenter` - Centro di lavoro
+- `ProductionOrder` - Ordine di produzione
+- `ProductionStep` - Fase produzione
+
+#### 5. Qualità (quality)
+
+**Funzionalità:**
+
+- Piani di controllo
+- Controlli in accettazione
+- Controlli in produzione
+- Non conformità
+- Azioni correttive
+- Certificazioni/documenti
+
+**Entità principali:**
+
+- `ControlPlan` - Piano di controllo
+- `QualityCheck` - Controllo qualità
+- `NonConformity` - Non conformità
+- `CorrectiveAction` - Azione correttiva
+- `Certificate` - Certificazione
+
+---
+
+### Problemi Risolti Durante Implementazione
+
+31. **EF Core Migration Fallita per Tabella Esistente (FIX 29/11/2025):**
+ - **Problema:** `dotnet ef database update` falliva con "Table 'Clienti' already exists"
+ - **Causa:** La migration tentava di ricreare tutte le tabelle invece di aggiungere solo quelle nuove
+ - **Soluzione:** Create tabelle manualmente via SQLite:
+ ```sql
+ CREATE TABLE AppModules (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ Code TEXT NOT NULL UNIQUE,
+ Name TEXT NOT NULL,
+ ...
+ );
+ CREATE TABLE ModuleSubscriptions (
+ Id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ModuleId INTEGER NOT NULL REFERENCES AppModules(Id),
+ ...
+ );
+ ```
+ - **File:** Database SQLite, rimossa migration problematica
+
+32. **TypeScript Unused Variables Build Errors (FIX 29/11/2025):**
+ - **Problema:** Build frontend falliva per variabili importate ma non usate
+ - **Soluzione:** Rimossi import inutilizzati:
+ - `ModuleGuard.tsx`: Rimosso `CircularProgress`, `showLoader`
+ - `ModuleContext.tsx`: Rimosso `useState`, `useEffect`
+ - `ModulePurchasePage.tsx`: Rimosso `moduleService` import
+ - `ModulesAdminPage.tsx`: Rimosso `PowerIcon`, `CheckIcon`, `CancelIcon`
+ - **File:** Vari componenti frontend
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e67fcfd..adc9e09 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -17,8 +17,11 @@ import RisorsePage from "./pages/RisorsePage";
import CalendarioPage from "./pages/CalendarioPage";
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
import ReportEditorPage from "./pages/ReportEditorPage";
+import ModulesAdminPage from "./pages/ModulesAdminPage";
+import ModulePurchasePage from "./pages/ModulePurchasePage";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext";
+import { ModuleProvider } from "./contexts/ModuleContext";
const queryClient = new QueryClient({
defaultOptions: {
@@ -60,34 +63,42 @@ function App() {
-
-
-
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- }
- />
- }
- />
- }
- />
-
-
-
-
+
+
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ }
+ />
+ }
+ />
+ {/* Moduli */}
+ } />
+ }
+ />
+
+
+
+
+
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index f02ec7e..9372299 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -27,6 +27,7 @@ import {
CalendarMonth as CalendarIcon,
Print as PrintIcon,
Close as CloseIcon,
+ Extension as ModulesIcon,
} from "@mui/icons-material";
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
@@ -42,6 +43,7 @@ const menuItems = [
{ text: "Articoli", icon: , path: "/articoli" },
{ text: "Risorse", icon: , path: "/risorse" },
{ text: "Report", icon: , path: "/report-templates" },
+ { text: "Moduli", icon: , path: "/modules" },
];
export default function Layout() {
diff --git a/frontend/src/components/ModuleGuard.tsx b/frontend/src/components/ModuleGuard.tsx
new file mode 100644
index 0000000..4e66476
--- /dev/null
+++ b/frontend/src/components/ModuleGuard.tsx
@@ -0,0 +1,75 @@
+import { Navigate, useLocation } from "react-router-dom";
+import { useModuleEnabled, useModule } from "../contexts/ModuleContext";
+import { Box, Typography } from "@mui/material";
+
+interface ModuleGuardProps {
+ /** Codice del modulo richiesto */
+ moduleCode: string;
+ /** Contenuto da renderizzare se il modulo è abilitato */
+ children: React.ReactNode;
+}
+
+/**
+ * Componente guard che protegge le route basandosi sullo stato del modulo.
+ * Se il modulo non è abilitato, reindirizza alla pagina di acquisto.
+ */
+export function ModuleGuard({ moduleCode, children }: ModuleGuardProps) {
+ const location = useLocation();
+ const isEnabled = useModuleEnabled(moduleCode);
+ const module = useModule(moduleCode);
+
+ // Se il modulo non esiste (codice errato), mostra errore
+ if (module === undefined) {
+ return (
+
+
+ Modulo non trovato
+
+
+ Il modulo "{moduleCode}" non esiste.
+
+
+ );
+ }
+
+ // Se il modulo è abilitato, mostra il contenuto
+ if (isEnabled) {
+ return <>{children}>;
+ }
+
+ // Modulo non abilitato: redirect alla pagina di acquisto
+ return (
+
+ );
+}
+
+/**
+ * HOC per proteggere un componente con ModuleGuard
+ */
+export function withModuleGuard
(
+ WrappedComponent: React.ComponentType
,
+ moduleCode: string,
+) {
+ return function ModuleGuardedComponent(props: P) {
+ return (
+
+
+
+ );
+ };
+}
+
+export default ModuleGuard;
diff --git a/frontend/src/contexts/ModuleContext.tsx b/frontend/src/contexts/ModuleContext.tsx
new file mode 100644
index 0000000..56d5bb1
--- /dev/null
+++ b/frontend/src/contexts/ModuleContext.tsx
@@ -0,0 +1,182 @@
+import { createContext, useContext, useCallback, type ReactNode } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { moduleService } from "../services/moduleService";
+import type {
+ ModuleDto,
+ EnableModuleRequest,
+ SubscriptionDto,
+} from "../types/module";
+
+// ==================== TYPES ====================
+
+export interface ModuleContextValue {
+ /** Lista di tutti i moduli disponibili */
+ modules: ModuleDto[];
+ /** Lista dei moduli attualmente attivi */
+ activeModules: ModuleDto[];
+ /** Codici dei moduli attivi (per filtro veloce) */
+ activeModuleCodes: string[];
+ /** Stato di caricamento */
+ isLoading: boolean;
+ /** Errore di caricamento */
+ error: Error | null;
+ /** Verifica se un modulo è abilitato */
+ isModuleEnabled: (code: string) => boolean;
+ /** Ottiene un modulo per codice */
+ getModule: (code: string) => ModuleDto | undefined;
+ /** Attiva un modulo */
+ enableModule: (
+ code: string,
+ request: EnableModuleRequest,
+ ) => Promise;
+ /** Disattiva un modulo */
+ disableModule: (code: string) => Promise;
+ /** Ricarica i dati dei moduli */
+ refreshModules: () => Promise;
+}
+
+const ModuleContext = createContext(null);
+
+// ==================== PROVIDER ====================
+
+interface ModuleProviderProps {
+ children: ReactNode;
+}
+
+export function ModuleProvider({ children }: ModuleProviderProps) {
+ const queryClient = useQueryClient();
+
+ // Query per tutti i moduli
+ const {
+ data: modules = [],
+ isLoading: isLoadingModules,
+ error: modulesError,
+ } = useQuery({
+ queryKey: ["modules"],
+ queryFn: moduleService.getAll,
+ staleTime: 5 * 60 * 1000, // 5 minuti
+ refetchOnWindowFocus: false,
+ });
+
+ // Query per moduli attivi
+ const {
+ data: activeModules = [],
+ isLoading: isLoadingActive,
+ error: activeError,
+ } = useQuery({
+ queryKey: ["modules", "active"],
+ queryFn: moduleService.getActive,
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ });
+
+ // Calcola i codici dei moduli attivi
+ const activeModuleCodes = activeModules.map((m) => m.code);
+
+ // Verifica se un modulo è abilitato
+ const isModuleEnabled = useCallback(
+ (code: string): boolean => {
+ return activeModuleCodes.includes(code);
+ },
+ [activeModuleCodes],
+ );
+
+ // Ottiene un modulo per codice
+ const getModule = useCallback(
+ (code: string): ModuleDto | undefined => {
+ return modules.find((m) => m.code === code);
+ },
+ [modules],
+ );
+
+ // Attiva un modulo
+ const enableModule = useCallback(
+ async (
+ code: string,
+ request: EnableModuleRequest,
+ ): Promise => {
+ const subscription = await moduleService.enable(code, request);
+ // Invalida le query per ricaricare i dati
+ await queryClient.invalidateQueries({ queryKey: ["modules"] });
+ return subscription;
+ },
+ [queryClient],
+ );
+
+ // Disattiva un modulo
+ const disableModule = useCallback(
+ async (code: string): Promise => {
+ await moduleService.disable(code);
+ // Invalida le query per ricaricare i dati
+ await queryClient.invalidateQueries({ queryKey: ["modules"] });
+ },
+ [queryClient],
+ );
+
+ // Ricarica i dati dei moduli
+ const refreshModules = useCallback(async (): Promise => {
+ await queryClient.invalidateQueries({ queryKey: ["modules"] });
+ }, [queryClient]);
+
+ const value: ModuleContextValue = {
+ modules,
+ activeModules,
+ activeModuleCodes,
+ isLoading: isLoadingModules || isLoadingActive,
+ error: modulesError || activeError,
+ isModuleEnabled,
+ getModule,
+ enableModule,
+ disableModule,
+ refreshModules,
+ };
+
+ return (
+ {children}
+ );
+}
+
+// ==================== HOOKS ====================
+
+/**
+ * Hook per accedere al context dei moduli
+ */
+export function useModules(): ModuleContextValue {
+ const context = useContext(ModuleContext);
+ if (!context) {
+ throw new Error("useModules must be used within a ModuleProvider");
+ }
+ return context;
+}
+
+/**
+ * Hook per verificare se un singolo modulo è abilitato
+ */
+export function useModuleEnabled(code: string): boolean {
+ const { isModuleEnabled } = useModules();
+ return isModuleEnabled(code);
+}
+
+/**
+ * Hook per ottenere solo i moduli attivi
+ */
+export function useActiveModules(): ModuleDto[] {
+ const { activeModules } = useModules();
+ return activeModules;
+}
+
+/**
+ * Hook per ottenere i codici dei moduli attivi
+ */
+export function useActiveModuleCodes(): string[] {
+ const { activeModuleCodes } = useModules();
+ return activeModuleCodes;
+}
+
+/**
+ * Hook per ottenere un modulo specifico
+ */
+export function useModule(code: string): ModuleDto | undefined {
+ const { getModule } = useModules();
+ return getModule(code);
+}
diff --git a/frontend/src/pages/ModulePurchasePage.tsx b/frontend/src/pages/ModulePurchasePage.tsx
new file mode 100644
index 0000000..6dd2257
--- /dev/null
+++ b/frontend/src/pages/ModulePurchasePage.tsx
@@ -0,0 +1,386 @@
+import { useState } from "react";
+import { useParams, useNavigate, useLocation } from "react-router-dom";
+import { useMutation } from "@tanstack/react-query";
+import {
+ Box,
+ Card,
+ CardContent,
+ Typography,
+ Button,
+ ToggleButton,
+ ToggleButtonGroup,
+ Alert,
+ CircularProgress,
+ Chip,
+ Divider,
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ Paper,
+} from "@mui/material";
+import {
+ CheckCircle as CheckIcon,
+ ArrowBack as BackIcon,
+ ShoppingCart as CartIcon,
+ CalendarMonth as MonthlyIcon,
+ CalendarToday as AnnualIcon,
+ Warning as WarningIcon,
+} from "@mui/icons-material";
+import { useModule, useModules } from "../contexts/ModuleContext";
+import {
+ SubscriptionType,
+ formatPrice,
+ getSubscriptionTypeName,
+} from "../types/module";
+
+export default function ModulePurchasePage() {
+ const { code } = useParams<{ code: string }>();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const module = useModule(code || "");
+ const { enableModule, isModuleEnabled } = useModules();
+
+ const [subscriptionType, setSubscriptionType] = useState(
+ SubscriptionType.Annual,
+ );
+ const [autoRenew, setAutoRenew] = useState(true);
+
+ // Mutation per attivare il modulo
+ const enableMutation = useMutation({
+ mutationFn: async () => {
+ if (!code) throw new Error("Codice modulo mancante");
+ return enableModule(code, {
+ subscriptionType,
+ autoRenew,
+ });
+ },
+ onSuccess: () => {
+ // Redirect alla pagina originale o alla home del modulo
+ const from = (location.state as { from?: string })?.from;
+ navigate(from || module?.routePath || "/");
+ },
+ });
+
+ // Se il modulo è già abilitato, redirect
+ if (code && isModuleEnabled(code)) {
+ navigate(module?.routePath || "/");
+ return null;
+ }
+
+ if (!module) {
+ return (
+
+
+ Modulo non trovato
+
+
+ Il modulo richiesto non esiste.
+
+ }
+ onClick={() => navigate("/")}
+ >
+ Torna alla Home
+
+
+ );
+ }
+
+ // Calcola il prezzo in base al tipo di subscription
+ const price =
+ subscriptionType === SubscriptionType.Monthly
+ ? module.monthlyPrice
+ : module.basePrice;
+
+ const priceLabel =
+ subscriptionType === SubscriptionType.Monthly ? "/mese" : "/anno";
+
+ // Calcola risparmio annuale
+ const annualSavings = module.monthlyPrice * 12 - module.basePrice;
+ const savingsPercent = Math.round(
+ (annualSavings / (module.monthlyPrice * 12)) * 100,
+ );
+
+ // Verifica dipendenze mancanti
+ const missingDependencies = module.dependencies.filter(
+ (dep) => !isModuleEnabled(dep),
+ );
+
+ return (
+
+ {/* Header */}
+
+ }
+ onClick={() => navigate(-1)}
+ sx={{ mb: 2 }}
+ >
+ Indietro
+
+
+ Attiva Modulo
+
+
+ Scegli il piano di abbonamento per il modulo {module.name}
+
+
+
+ {/* Alert dipendenze mancanti */}
+ {missingDependencies.length > 0 && (
+ }>
+
+ Questo modulo richiede i seguenti moduli che non sono attivi:
+
+
+ {missingDependencies.map((dep) => (
+ navigate(`/modules/purchase/${dep}`)}
+ />
+ ))}
+
+
+ )}
+
+ {/* Card principale */}
+
+
+ {/* Info modulo */}
+
+
+ {module.name}
+
+
+ {module.description}
+
+
+
+
+
+ {/* Selezione tipo abbonamento */}
+
+
+ Tipo di abbonamento
+
+ value && setSubscriptionType(value)}
+ fullWidth
+ sx={{ mb: 2 }}
+ >
+
+
+
+
+ Mensile
+
+
+ {formatPrice(module.monthlyPrice)}
+
+ /mese
+
+
+
+
+
+
+
+
+ Annuale
+
+
+ {formatPrice(module.basePrice)}
+
+ /anno
+
+
+ {savingsPercent > 0 && (
+
+ )}
+
+
+
+
+ {/* Opzione auto-rinnovo */}
+
+ setAutoRenew(!autoRenew)}
+ size="small"
+ >
+ {autoRenew ? : null}
+
+
+ Rinnovo automatico alla scadenza
+
+
+
+
+
+
+ {/* Riepilogo */}
+
+
+ Riepilogo ordine
+
+
+ Modulo {module.name}
+ {formatPrice(price)}
+
+
+
+ Abbonamento{" "}
+ {getSubscriptionTypeName(subscriptionType).toLowerCase()}
+
+ {priceLabel}
+
+
+
+ Totale
+
+ {formatPrice(price)}
+ {priceLabel}
+
+
+
+
+ {/* Errore */}
+ {enableMutation.isError && (
+
+ {(enableMutation.error as Error)?.message ||
+ "Errore durante l'attivazione del modulo"}
+
+ )}
+
+ {/* Pulsante attivazione */}
+
+ ) : (
+
+ )
+ }
+ onClick={() => enableMutation.mutate()}
+ disabled={
+ enableMutation.isPending || missingDependencies.length > 0
+ }
+ >
+ {enableMutation.isPending
+ ? "Attivazione in corso..."
+ : "Attiva Modulo"}
+
+
+ {/* Note */}
+
+ Potrai disattivare il modulo in qualsiasi momento dalle
+ impostazioni. I dati inseriti rimarranno disponibili.
+
+
+
+
+ {/* Funzionalità incluse */}
+
+
+
+ Funzionalità incluse
+
+
+ {getModuleFeatures(module.code).map((feature, index) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// Helper per ottenere le funzionalità di un modulo
+function getModuleFeatures(code: string): string[] {
+ const features: Record = {
+ warehouse: [
+ "Gestione anagrafica articoli",
+ "Movimenti di magazzino (carico/scarico)",
+ "Giacenze in tempo reale",
+ "Valorizzazione scorte (FIFO, LIFO, medio ponderato)",
+ "Inventario e rettifiche",
+ "Report giacenze e movimenti",
+ ],
+ purchases: [
+ "Gestione ordini a fornitore",
+ "DDT di entrata",
+ "Fatture passive",
+ "Scadenziario pagamenti",
+ "Analisi acquisti per fornitore/articolo",
+ "Storico prezzi di acquisto",
+ ],
+ sales: [
+ "Gestione ordini cliente",
+ "DDT di uscita",
+ "Fatturazione elettronica",
+ "Scadenziario incassi",
+ "Analisi vendite per cliente/articolo",
+ "Listini prezzi",
+ ],
+ production: [
+ "Distinte base multilivello",
+ "Cicli di lavoro",
+ "Ordini di produzione",
+ "Pianificazione MRP",
+ "Avanzamento produzione",
+ "Costi di produzione",
+ ],
+ quality: [
+ "Piani di controllo",
+ "Registrazione controlli",
+ "Gestione non conformità",
+ "Azioni correttive/preventive",
+ "Certificazioni e audit",
+ "Statistiche qualità",
+ ],
+ };
+
+ return features[code] || ["Funzionalità complete del modulo"];
+}
diff --git a/frontend/src/pages/ModulesAdminPage.tsx b/frontend/src/pages/ModulesAdminPage.tsx
new file mode 100644
index 0000000..c5cf8b6
--- /dev/null
+++ b/frontend/src/pages/ModulesAdminPage.tsx
@@ -0,0 +1,515 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ Box,
+ Card,
+ CardContent,
+ Typography,
+ Button,
+ Chip,
+ Grid,
+ IconButton,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Alert,
+ CircularProgress,
+ Tooltip,
+ LinearProgress,
+ Divider,
+ Switch,
+ FormControlLabel,
+} from "@mui/material";
+import {
+ Refresh as RefreshIcon,
+ Info as InfoIcon,
+ Warning as WarningIcon,
+ Schedule as ScheduleIcon,
+ Autorenew as RenewIcon,
+} from "@mui/icons-material";
+import * as Icons from "@mui/icons-material";
+import { useModules } from "../contexts/ModuleContext";
+import { moduleService } from "../services/moduleService";
+import type { ModuleDto } from "../types/module";
+import {
+ formatPrice,
+ formatDate,
+ getDaysRemainingText,
+ getSubscriptionStatusColor,
+ getSubscriptionStatusText,
+} from "../types/module";
+
+export default function ModulesAdminPage() {
+ const navigate = useNavigate();
+ const { modules, isLoading, refreshModules } = useModules();
+ const [selectedModule, setSelectedModule] = useState(null);
+ const [confirmDisable, setConfirmDisable] = useState(null);
+
+ // Query per moduli in scadenza
+ const { data: expiringModules = [] } = useQuery({
+ queryKey: ["modules", "expiring"],
+ queryFn: () => moduleService.getExpiring(30),
+ });
+
+ // Mutation per disattivare modulo
+ const disableMutation = useMutation({
+ mutationFn: (code: string) => moduleService.disable(code),
+ onSuccess: () => {
+ refreshModules();
+ setConfirmDisable(null);
+ },
+ });
+
+ // Mutation per rinnovare subscription
+ const renewMutation = useMutation({
+ mutationFn: (code: string) => moduleService.renewSubscription(code),
+ onSuccess: () => {
+ refreshModules();
+ },
+ });
+
+ // Mutation per controllare scadenze
+ const checkExpiredMutation = useMutation({
+ mutationFn: () => moduleService.checkExpired(),
+ onSuccess: () => {
+ refreshModules();
+ },
+ });
+
+ // Helper per ottenere icona modulo
+ const getModuleIcon = (iconName?: string) => {
+ if (!iconName) return ;
+ const IconComponent = (Icons as Record)[
+ iconName.replace(/\s+/g, "")
+ ];
+ return IconComponent ? : ;
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Gestione Moduli
+
+
+ Configura i moduli attivi e gestisci le subscription
+
+
+
+ }
+ onClick={() => checkExpiredMutation.mutate()}
+ disabled={checkExpiredMutation.isPending}
+ >
+ Controlla Scadenze
+
+ }
+ onClick={() => refreshModules()}
+ >
+ Aggiorna
+
+
+
+
+ {/* Alert moduli in scadenza */}
+ {expiringModules.length > 0 && (
+ }>
+
+ {expiringModules.length} modulo/i in scadenza nei prossimi 30
+ giorni:
+
+
+ {expiringModules.map((m) => (
+
+ ))}
+
+
+ )}
+
+ {/* Griglia moduli */}
+
+ {modules.map((module) => (
+
+ {
+ if (module.isEnabled && !module.isCore) {
+ setConfirmDisable(module.code);
+ } else if (!module.isEnabled) {
+ navigate(`/modules/purchase/${module.code}`);
+ }
+ }}
+ onInfo={() => setSelectedModule(module)}
+ onRenew={() => renewMutation.mutate(module.code)}
+ isRenewing={renewMutation.isPending}
+ getIcon={getModuleIcon}
+ />
+
+ ))}
+
+
+ {/* Dialog dettagli modulo */}
+
+
+ {/* Dialog conferma disattivazione */}
+
+
+ );
+}
+
+// Componente Card singolo modulo
+interface ModuleCardProps {
+ module: ModuleDto;
+ onToggle: () => void;
+ onInfo: () => void;
+ onRenew: () => void;
+ isRenewing: boolean;
+ getIcon: (iconName?: string) => React.ReactNode;
+}
+
+function ModuleCard({
+ module,
+ onToggle,
+ onInfo,
+ onRenew,
+ isRenewing,
+ getIcon,
+}: ModuleCardProps) {
+ const statusColor = getSubscriptionStatusColor(module.subscription);
+ const statusText = getSubscriptionStatusText(module.subscription);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ {getIcon(module.icon)}
+
+ {module.name}
+
+
+ {module.isCore ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Descrizione */}
+
+ {module.description}
+
+
+ {/* Info subscription */}
+ {module.subscription && module.isEnabled && (
+
+
+ Scadenza: {formatDate(module.subscription.endDate)}
+ {module.subscription.daysRemaining !== undefined &&
+ module.subscription.daysRemaining <= 30 && (
+
+ )}
+
+
+ )}
+
+ {/* Prezzo */}
+
+ {formatPrice(module.basePrice)}
+
+ /anno
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+ {module.isEnabled &&
+ !module.isCore &&
+ module.subscription?.isExpiringSoon && (
+
+
+ {isRenewing ? : }
+
+
+ )}
+
+
+ {!module.isCore && (
+
+ }
+ label={module.isEnabled ? "Attivo" : "Disattivo"}
+ labelPlacement="start"
+ />
+ )}
+
+
+ );
+}
diff --git a/frontend/src/services/moduleService.ts b/frontend/src/services/moduleService.ts
new file mode 100644
index 0000000..41f8ab6
--- /dev/null
+++ b/frontend/src/services/moduleService.ts
@@ -0,0 +1,128 @@
+import api from "./api";
+import type {
+ ModuleDto,
+ ModuleStatusDto,
+ SubscriptionDto,
+ EnableModuleRequest,
+ UpdateSubscriptionRequest,
+ RenewSubscriptionRequest,
+} from "../types/module";
+
+/**
+ * Service per la gestione dei moduli applicativi
+ */
+export const moduleService = {
+ /**
+ * Ottiene tutti i moduli disponibili con stato subscription
+ */
+ getAll: async (): Promise => {
+ const response = await api.get("/modules");
+ return response.data;
+ },
+
+ /**
+ * Ottiene solo i moduli attivi (per costruzione menu)
+ */
+ getActive: async (): Promise => {
+ const response = await api.get("/modules/active");
+ return response.data;
+ },
+
+ /**
+ * Ottiene un modulo specifico per codice
+ */
+ getByCode: async (code: string): Promise => {
+ const response = await api.get(`/modules/${code}`);
+ return response.data;
+ },
+
+ /**
+ * Verifica se un modulo è abilitato
+ */
+ isEnabled: async (code: string): Promise => {
+ const response = await api.get(`/modules/${code}/enabled`);
+ return response.data;
+ },
+
+ /**
+ * Attiva un modulo
+ */
+ enable: async (code: string, request: EnableModuleRequest): Promise => {
+ const response = await api.put(`/modules/${code}/enable`, request);
+ return response.data;
+ },
+
+ /**
+ * Disattiva un modulo
+ */
+ disable: async (code: string): Promise<{ message: string }> => {
+ const response = await api.put(`/modules/${code}/disable`);
+ return response.data;
+ },
+
+ /**
+ * Ottiene tutte le subscription
+ */
+ getAllSubscriptions: async (): Promise => {
+ const response = await api.get("/modules/subscriptions");
+ return response.data;
+ },
+
+ /**
+ * Aggiorna la subscription di un modulo
+ */
+ updateSubscription: async (
+ code: string,
+ request: UpdateSubscriptionRequest
+ ): Promise => {
+ const response = await api.put(`/modules/${code}/subscription`, request);
+ return response.data;
+ },
+
+ /**
+ * Rinnova la subscription di un modulo
+ */
+ renewSubscription: async (
+ code: string,
+ request?: RenewSubscriptionRequest
+ ): Promise => {
+ const response = await api.post(`/modules/${code}/subscription/renew`, request || {});
+ return response.data;
+ },
+
+ /**
+ * Ottiene i moduli in scadenza
+ */
+ getExpiring: async (daysThreshold: number = 30): Promise => {
+ const response = await api.get("/modules/expiring", {
+ params: { daysThreshold },
+ });
+ return response.data;
+ },
+
+ /**
+ * Inizializza i moduli di default (admin)
+ */
+ seedDefault: async (): Promise<{ message: string }> => {
+ const response = await api.post("/modules/seed");
+ return response.data;
+ },
+
+ /**
+ * Forza il controllo delle subscription scadute (admin)
+ */
+ checkExpired: async (): Promise<{ message: string }> => {
+ const response = await api.post("/modules/check-expired");
+ return response.data;
+ },
+
+ /**
+ * Invalida la cache dei moduli (admin)
+ */
+ invalidateCache: async (): Promise<{ message: string }> => {
+ const response = await api.post("/modules/invalidate-cache");
+ return response.data;
+ },
+};
+
+export default moduleService;
diff --git a/frontend/src/types/module.ts b/frontend/src/types/module.ts
new file mode 100644
index 0000000..9651da7
--- /dev/null
+++ b/frontend/src/types/module.ts
@@ -0,0 +1,174 @@
+/**
+ * Tipi di subscription per i moduli
+ */
+export enum SubscriptionType {
+ None = 0,
+ Monthly = 1,
+ Annual = 2,
+}
+
+/**
+ * Informazioni sulla subscription di un modulo
+ */
+export interface SubscriptionDto {
+ id: number;
+ moduleId: number;
+ moduleCode?: string;
+ moduleName?: string;
+ isEnabled: boolean;
+ subscriptionType: SubscriptionType;
+ subscriptionTypeName: string;
+ startDate?: string;
+ endDate?: string;
+ autoRenew: boolean;
+ notes?: string;
+ lastRenewalDate?: string;
+ paidPrice?: number;
+ isValid: boolean;
+ daysRemaining?: number;
+ isExpiringSoon: boolean;
+}
+
+/**
+ * Rappresenta un modulo dell'applicazione
+ */
+export interface ModuleDto {
+ id: number;
+ code: string;
+ name: string;
+ description?: string;
+ icon?: string;
+ basePrice: number;
+ monthlyPrice: number;
+ monthlyMultiplier: number;
+ sortOrder: number;
+ isCore: boolean;
+ dependencies: string[];
+ routePath?: string;
+ isAvailable: boolean;
+ isEnabled: boolean;
+ subscription?: SubscriptionDto;
+}
+
+/**
+ * Stato di un modulo (risposta da /api/modules/{code}/enabled)
+ */
+export interface ModuleStatusDto {
+ code: string;
+ isEnabled: boolean;
+ hasValidSubscription: boolean;
+ isCore: boolean;
+ daysRemaining?: number;
+ isExpiringSoon: boolean;
+}
+
+/**
+ * Request per attivare un modulo
+ */
+export interface EnableModuleRequest {
+ subscriptionType?: SubscriptionType;
+ startDate?: string;
+ endDate?: string;
+ autoRenew?: boolean;
+ paidPrice?: number;
+ notes?: string;
+}
+
+/**
+ * Request per aggiornare una subscription
+ */
+export interface UpdateSubscriptionRequest {
+ subscriptionType?: SubscriptionType;
+ endDate?: string;
+ autoRenew?: boolean;
+ notes?: string;
+}
+
+/**
+ * Request per rinnovare una subscription
+ */
+export interface RenewSubscriptionRequest {
+ paidPrice?: number;
+}
+
+/**
+ * Helper per ottenere il nome visualizzato del tipo subscription
+ */
+export function getSubscriptionTypeName(type: SubscriptionType): string {
+ switch (type) {
+ case SubscriptionType.None:
+ return "Nessuno";
+ case SubscriptionType.Monthly:
+ return "Mensile";
+ case SubscriptionType.Annual:
+ return "Annuale";
+ default:
+ return "Sconosciuto";
+ }
+}
+
+/**
+ * Helper per formattare il prezzo
+ */
+export function formatPrice(price: number): string {
+ return new Intl.NumberFormat("it-IT", {
+ style: "currency",
+ currency: "EUR",
+ }).format(price);
+}
+
+/**
+ * Helper per formattare la data
+ */
+export function formatDate(dateString?: string): string {
+ if (!dateString) return "-";
+ return new Date(dateString).toLocaleDateString("it-IT", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ });
+}
+
+/**
+ * Helper per calcolare i giorni rimanenti
+ */
+export function getDaysRemainingText(days?: number): string {
+ if (days === undefined || days === null) return "Nessuna scadenza";
+ if (days === 0) return "Scaduto";
+ if (days === 1) return "1 giorno";
+ return `${days} giorni`;
+}
+
+/**
+ * Helper per ottenere il colore dello stato subscription
+ */
+export function getSubscriptionStatusColor(
+ subscription?: SubscriptionDto
+): "success" | "warning" | "error" | "default" {
+ if (!subscription || !subscription.isEnabled) return "default";
+ if (!subscription.isValid) return "error";
+ if (subscription.isExpiringSoon) return "warning";
+ return "success";
+}
+
+/**
+ * Helper per ottenere il testo dello stato subscription
+ */
+export function getSubscriptionStatusText(subscription?: SubscriptionDto): string {
+ if (!subscription) return "Non attivo";
+ if (!subscription.isEnabled) return "Disattivato";
+ if (!subscription.isValid) return "Scaduto";
+ if (subscription.isExpiringSoon) return "In scadenza";
+ return "Attivo";
+}
+
+/**
+ * Mappa delle icone MUI per i moduli
+ */
+export const moduleIcons: Record = {
+ warehouse: "Warehouse",
+ purchases: "ShoppingCart",
+ sales: "PointOfSale",
+ production: "PrecisionManufacturing",
+ quality: "VerifiedUser",
+};
diff --git a/src/Apollinare.API/Apollinare.API.csproj b/src/Apollinare.API/Apollinare.API.csproj
index 69fb0a6..c7d3934 100644
--- a/src/Apollinare.API/Apollinare.API.csproj
+++ b/src/Apollinare.API/Apollinare.API.csproj
@@ -8,6 +8,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/src/Apollinare.API/Controllers/ModulesController.cs b/src/Apollinare.API/Controllers/ModulesController.cs
new file mode 100644
index 0000000..0758fe3
--- /dev/null
+++ b/src/Apollinare.API/Controllers/ModulesController.cs
@@ -0,0 +1,352 @@
+using Apollinare.API.Services;
+using Apollinare.Domain.Entities;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Apollinare.API.Controllers;
+
+///
+/// Controller per la gestione dei moduli applicativi e delle subscription
+///
+[ApiController]
+[Route("api/[controller]")]
+public class ModulesController : ControllerBase
+{
+ private readonly ModuleService _moduleService;
+ private readonly ILogger _logger;
+
+ public ModulesController(ModuleService moduleService, ILogger logger)
+ {
+ _moduleService = moduleService;
+ _logger = logger;
+ }
+
+ ///
+ /// Ottiene tutti i moduli disponibili con stato subscription
+ ///
+ [HttpGet]
+ public async Task>> GetAllModules()
+ {
+ var modules = await _moduleService.GetAllModulesAsync();
+ return Ok(modules.Select(MapToDto).ToList());
+ }
+
+ ///
+ /// Ottiene solo i moduli attivi (per costruzione menu)
+ ///
+ [HttpGet("active")]
+ public async Task>> GetActiveModules()
+ {
+ var modules = await _moduleService.GetActiveModulesAsync();
+ return Ok(modules.Select(MapToDto).ToList());
+ }
+
+ ///
+ /// Ottiene un modulo specifico per codice
+ ///
+ [HttpGet("{code}")]
+ public async Task> GetModule(string code)
+ {
+ var module = await _moduleService.GetModuleByCodeAsync(code);
+ if (module == null)
+ return NotFound(new { message = $"Modulo '{code}' non trovato" });
+
+ return Ok(MapToDto(module));
+ }
+
+ ///
+ /// Verifica se un modulo è abilitato
+ ///
+ [HttpGet("{code}/enabled")]
+ public async Task> IsModuleEnabled(string code)
+ {
+ var module = await _moduleService.GetModuleByCodeAsync(code);
+ if (module == null)
+ return NotFound(new { message = $"Modulo '{code}' non trovato" });
+
+ var isEnabled = await _moduleService.IsModuleEnabledAsync(code);
+ var hasValidSubscription = await _moduleService.HasValidSubscriptionAsync(code);
+
+ return Ok(new ModuleStatusDto
+ {
+ Code = code,
+ IsEnabled = isEnabled,
+ HasValidSubscription = hasValidSubscription,
+ IsCore = module.IsCore,
+ DaysRemaining = module.Subscription?.GetDaysRemaining(),
+ IsExpiringSoon = module.Subscription?.IsExpiringSoon() ?? false
+ });
+ }
+
+ ///
+ /// Attiva un modulo
+ ///
+ [HttpPut("{code}/enable")]
+ public async Task> EnableModule(string code, [FromBody] EnableModuleRequest request)
+ {
+ try
+ {
+ var subscription = await _moduleService.EnableModuleAsync(
+ code,
+ request.SubscriptionType,
+ request.StartDate,
+ request.EndDate,
+ request.AutoRenew,
+ request.PaidPrice,
+ request.Notes);
+
+ return Ok(MapSubscriptionToDto(subscription));
+ }
+ catch (ArgumentException ex)
+ {
+ return NotFound(new { message = ex.Message });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(new { message = ex.Message });
+ }
+ }
+
+ ///
+ /// Disattiva un modulo
+ ///
+ [HttpPut("{code}/disable")]
+ public async Task DisableModule(string code)
+ {
+ try
+ {
+ await _moduleService.DisableModuleAsync(code);
+ return Ok(new { message = $"Modulo '{code}' disattivato" });
+ }
+ catch (ArgumentException ex)
+ {
+ return NotFound(new { message = ex.Message });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(new { message = ex.Message });
+ }
+ }
+
+ ///
+ /// Ottiene tutte le subscription
+ ///
+ [HttpGet("subscriptions")]
+ public async Task>> GetAllSubscriptions()
+ {
+ var subscriptions = await _moduleService.GetAllSubscriptionsAsync();
+ return Ok(subscriptions.Select(MapSubscriptionToDto).ToList());
+ }
+
+ ///
+ /// Aggiorna la subscription di un modulo
+ ///
+ [HttpPut("{code}/subscription")]
+ public async Task> UpdateSubscription(string code, [FromBody] UpdateSubscriptionRequest request)
+ {
+ try
+ {
+ var subscription = await _moduleService.UpdateSubscriptionAsync(
+ code,
+ request.SubscriptionType,
+ request.EndDate,
+ request.AutoRenew,
+ request.Notes);
+
+ return Ok(MapSubscriptionToDto(subscription));
+ }
+ catch (ArgumentException ex)
+ {
+ return NotFound(new { message = ex.Message });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(new { message = ex.Message });
+ }
+ }
+
+ ///
+ /// Rinnova la subscription di un modulo
+ ///
+ [HttpPost("{code}/subscription/renew")]
+ public async Task> RenewSubscription(string code, [FromBody] RenewSubscriptionRequest? request = null)
+ {
+ try
+ {
+ var subscription = await _moduleService.RenewSubscriptionAsync(code, request?.PaidPrice);
+ return Ok(MapSubscriptionToDto(subscription));
+ }
+ catch (ArgumentException ex)
+ {
+ return NotFound(new { message = ex.Message });
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(new { message = ex.Message });
+ }
+ }
+
+ ///
+ /// Ottiene i moduli in scadenza
+ ///
+ [HttpGet("expiring")]
+ public async Task>> GetExpiringModules([FromQuery] int daysThreshold = 30)
+ {
+ var modules = await _moduleService.GetExpiringModulesAsync(daysThreshold);
+ return Ok(modules.Select(MapToDto).ToList());
+ }
+
+ ///
+ /// Inizializza i moduli di default (per setup iniziale)
+ ///
+ [HttpPost("seed")]
+ public async Task SeedDefaultModules()
+ {
+ await _moduleService.SeedDefaultModulesAsync();
+ return Ok(new { message = "Moduli di default inizializzati" });
+ }
+
+ ///
+ /// Forza il controllo delle subscription scadute
+ ///
+ [HttpPost("check-expired")]
+ public async Task CheckExpiredSubscriptions()
+ {
+ var count = await _moduleService.CheckExpiredSubscriptionsAsync();
+ return Ok(new { message = $"Controllate le subscription, {count} moduli disattivati per scadenza" });
+ }
+
+ ///
+ /// Invalida la cache dei moduli
+ ///
+ [HttpPost("invalidate-cache")]
+ public ActionResult InvalidateCache()
+ {
+ _moduleService.InvalidateCache();
+ return Ok(new { message = "Cache moduli invalidata" });
+ }
+
+ #region Mapping
+
+ private static ModuleDto MapToDto(AppModule module)
+ {
+ return new ModuleDto
+ {
+ Id = module.Id,
+ Code = module.Code,
+ Name = module.Name,
+ Description = module.Description,
+ Icon = module.Icon,
+ BasePrice = module.BasePrice,
+ MonthlyPrice = module.GetMonthlyPrice(),
+ MonthlyMultiplier = module.MonthlyMultiplier,
+ SortOrder = module.SortOrder,
+ IsCore = module.IsCore,
+ Dependencies = module.GetDependencies().ToList(),
+ RoutePath = module.RoutePath,
+ IsAvailable = module.IsAvailable,
+ IsEnabled = module.IsCore || (module.Subscription?.IsValid() ?? false),
+ Subscription = module.Subscription != null ? MapSubscriptionToDto(module.Subscription) : null
+ };
+ }
+
+ private static SubscriptionDto MapSubscriptionToDto(ModuleSubscription subscription)
+ {
+ return new SubscriptionDto
+ {
+ Id = subscription.Id,
+ ModuleId = subscription.ModuleId,
+ ModuleCode = subscription.Module?.Code,
+ ModuleName = subscription.Module?.Name,
+ IsEnabled = subscription.IsEnabled,
+ SubscriptionType = subscription.SubscriptionType,
+ SubscriptionTypeName = subscription.SubscriptionType.ToString(),
+ StartDate = subscription.StartDate,
+ EndDate = subscription.EndDate,
+ AutoRenew = subscription.AutoRenew,
+ Notes = subscription.Notes,
+ LastRenewalDate = subscription.LastRenewalDate,
+ PaidPrice = subscription.PaidPrice,
+ IsValid = subscription.IsValid(),
+ DaysRemaining = subscription.GetDaysRemaining(),
+ IsExpiringSoon = subscription.IsExpiringSoon()
+ };
+ }
+
+ #endregion
+}
+
+#region DTOs
+
+public class ModuleDto
+{
+ public int Id { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string? Icon { get; set; }
+ public decimal BasePrice { get; set; }
+ public decimal MonthlyPrice { get; set; }
+ public decimal MonthlyMultiplier { get; set; }
+ public int SortOrder { get; set; }
+ public bool IsCore { get; set; }
+ public List Dependencies { get; set; } = new();
+ public string? RoutePath { get; set; }
+ public bool IsAvailable { get; set; }
+ public bool IsEnabled { get; set; }
+ public SubscriptionDto? Subscription { get; set; }
+}
+
+public class SubscriptionDto
+{
+ public int Id { get; set; }
+ public int ModuleId { get; set; }
+ public string? ModuleCode { get; set; }
+ public string? ModuleName { get; set; }
+ public bool IsEnabled { get; set; }
+ public SubscriptionType SubscriptionType { get; set; }
+ public string SubscriptionTypeName { get; set; } = string.Empty;
+ public DateTime? StartDate { get; set; }
+ public DateTime? EndDate { get; set; }
+ public bool AutoRenew { get; set; }
+ public string? Notes { get; set; }
+ public DateTime? LastRenewalDate { get; set; }
+ public decimal? PaidPrice { get; set; }
+ public bool IsValid { get; set; }
+ public int? DaysRemaining { get; set; }
+ public bool IsExpiringSoon { get; set; }
+}
+
+public class ModuleStatusDto
+{
+ public string Code { get; set; } = string.Empty;
+ public bool IsEnabled { get; set; }
+ public bool HasValidSubscription { get; set; }
+ public bool IsCore { get; set; }
+ public int? DaysRemaining { get; set; }
+ public bool IsExpiringSoon { get; set; }
+}
+
+public class EnableModuleRequest
+{
+ public SubscriptionType SubscriptionType { get; set; } = SubscriptionType.Annual;
+ public DateTime? StartDate { get; set; }
+ public DateTime? EndDate { get; set; }
+ public bool AutoRenew { get; set; }
+ public decimal? PaidPrice { get; set; }
+ public string? Notes { get; set; }
+}
+
+public class UpdateSubscriptionRequest
+{
+ public SubscriptionType? SubscriptionType { get; set; }
+ public DateTime? EndDate { get; set; }
+ public bool? AutoRenew { get; set; }
+ public string? Notes { get; set; }
+}
+
+public class RenewSubscriptionRequest
+{
+ public decimal? PaidPrice { get; set; }
+}
+
+#endregion
diff --git a/src/Apollinare.API/Program.cs b/src/Apollinare.API/Program.cs
index e9497a6..502f013 100644
--- a/src/Apollinare.API/Program.cs
+++ b/src/Apollinare.API/Program.cs
@@ -17,8 +17,12 @@ builder.Services.AddDbContext(options =>
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
+// Memory cache for module state
+builder.Services.AddMemoryCache();
+
// SignalR - with increased message size for template sync (default is 32KB)
builder.Services.AddSignalR(options =>
{
@@ -61,6 +65,11 @@ if (app.Environment.IsDevelopment())
var db = scope.ServiceProvider.GetRequiredService();
db.Database.EnsureCreated();
DbSeeder.Seed(db);
+
+ // Seed default modules
+ var moduleService = scope.ServiceProvider.GetRequiredService();
+ await moduleService.SeedDefaultModulesAsync();
+
app.MapOpenApi();
}
diff --git a/src/Apollinare.API/Services/ModuleService.cs b/src/Apollinare.API/Services/ModuleService.cs
new file mode 100644
index 0000000..5fcf18b
--- /dev/null
+++ b/src/Apollinare.API/Services/ModuleService.cs
@@ -0,0 +1,493 @@
+using Apollinare.Domain.Entities;
+using Apollinare.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Apollinare.API.Services;
+
+///
+/// Service per la gestione dei moduli applicativi e delle relative subscription
+///
+public class ModuleService
+{
+ private readonly AppollinareDbContext _context;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger _logger;
+
+ private const string MODULES_CACHE_KEY = "modules_all";
+ private const string ACTIVE_MODULES_CACHE_KEY = "modules_active";
+ private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
+
+ public ModuleService(
+ AppollinareDbContext context,
+ IMemoryCache cache,
+ ILogger logger)
+ {
+ _context = context;
+ _cache = cache;
+ _logger = logger;
+ }
+
+ ///
+ /// Ottiene tutti i moduli con lo stato della subscription
+ ///
+ public async Task> GetAllModulesAsync()
+ {
+ return await _cache.GetOrCreateAsync(MODULES_CACHE_KEY, async entry =>
+ {
+ entry.AbsoluteExpirationRelativeToNow = CacheDuration;
+ return await _context.AppModules
+ .Include(m => m.Subscription)
+ .Where(m => m.IsAvailable)
+ .OrderBy(m => m.SortOrder)
+ .ThenBy(m => m.Name)
+ .ToListAsync();
+ }) ?? new List();
+ }
+
+ ///
+ /// Ottiene solo i moduli attivi (per la costruzione del menu)
+ ///
+ public async Task> GetActiveModulesAsync()
+ {
+ return await _cache.GetOrCreateAsync(ACTIVE_MODULES_CACHE_KEY, async entry =>
+ {
+ entry.AbsoluteExpirationRelativeToNow = CacheDuration;
+ var modules = await _context.AppModules
+ .Include(m => m.Subscription)
+ .Where(m => m.IsAvailable)
+ .OrderBy(m => m.SortOrder)
+ .ThenBy(m => m.Name)
+ .ToListAsync();
+
+ return modules.Where(m => m.IsCore || (m.Subscription?.IsValid() ?? false)).ToList();
+ }) ?? new List();
+ }
+
+ ///
+ /// Ottiene un modulo specifico per codice
+ ///
+ public async Task GetModuleByCodeAsync(string code)
+ {
+ return await _context.AppModules
+ .Include(m => m.Subscription)
+ .FirstOrDefaultAsync(m => m.Code == code);
+ }
+
+ ///
+ /// Verifica se un modulo è attualmente abilitato
+ ///
+ public async Task IsModuleEnabledAsync(string code)
+ {
+ var module = await GetModuleByCodeAsync(code);
+ if (module == null)
+ return false;
+
+ // I moduli core sono sempre abilitati
+ if (module.IsCore)
+ return true;
+
+ return module.Subscription?.IsValid() ?? false;
+ }
+
+ ///
+ /// Verifica se un modulo ha una subscription valida (non scaduta)
+ ///
+ public async Task HasValidSubscriptionAsync(string code)
+ {
+ var module = await GetModuleByCodeAsync(code);
+ return module?.Subscription?.IsValid() ?? false;
+ }
+
+ ///
+ /// Attiva un modulo creando o aggiornando la subscription
+ ///
+ public async Task EnableModuleAsync(
+ string code,
+ SubscriptionType subscriptionType,
+ DateTime? startDate = null,
+ DateTime? endDate = null,
+ bool autoRenew = false,
+ decimal? paidPrice = null,
+ string? notes = null)
+ {
+ var module = await _context.AppModules
+ .Include(m => m.Subscription)
+ .FirstOrDefaultAsync(m => m.Code == code);
+
+ if (module == null)
+ throw new ArgumentException($"Modulo con codice '{code}' non trovato");
+
+ if (module.IsCore)
+ throw new InvalidOperationException("I moduli core non possono essere attivati/disattivati manualmente");
+
+ // Verifica dipendenze
+ var missingDeps = await CheckDependenciesAsync(module);
+ if (missingDeps.Any())
+ throw new InvalidOperationException(
+ $"Il modulo richiede i seguenti moduli attivi: {string.Join(", ", missingDeps)}");
+
+ var now = DateTime.UtcNow;
+ var effectiveStartDate = startDate ?? now;
+
+ // Calcola data fine se non specificata
+ DateTime? effectiveEndDate = endDate;
+ if (!effectiveEndDate.HasValue && subscriptionType != SubscriptionType.None)
+ {
+ effectiveEndDate = subscriptionType switch
+ {
+ SubscriptionType.Monthly => effectiveStartDate.AddMonths(1),
+ SubscriptionType.Annual => effectiveStartDate.AddYears(1),
+ _ => null
+ };
+ }
+
+ if (module.Subscription == null)
+ {
+ // Crea nuova subscription
+ module.Subscription = new ModuleSubscription
+ {
+ ModuleId = module.Id,
+ IsEnabled = true,
+ SubscriptionType = subscriptionType,
+ StartDate = effectiveStartDate,
+ EndDate = effectiveEndDate,
+ AutoRenew = autoRenew,
+ PaidPrice = paidPrice ?? module.BasePrice,
+ Notes = notes,
+ CreatedAt = now,
+ UpdatedAt = now
+ };
+ _context.ModuleSubscriptions.Add(module.Subscription);
+ }
+ else
+ {
+ // Aggiorna subscription esistente
+ module.Subscription.IsEnabled = true;
+ module.Subscription.SubscriptionType = subscriptionType;
+ module.Subscription.StartDate = effectiveStartDate;
+ module.Subscription.EndDate = effectiveEndDate;
+ module.Subscription.AutoRenew = autoRenew;
+ module.Subscription.PaidPrice = paidPrice ?? module.Subscription.PaidPrice ?? module.BasePrice;
+ if (notes != null) module.Subscription.Notes = notes;
+ module.Subscription.UpdatedAt = now;
+ }
+
+ await _context.SaveChangesAsync();
+ InvalidateCache();
+
+ _logger.LogInformation(
+ "Modulo {ModuleCode} attivato con subscription {Type} fino a {EndDate}",
+ code, subscriptionType, effectiveEndDate);
+
+ return module.Subscription;
+ }
+
+ ///
+ /// Disattiva un modulo (mantiene i dati ma rimuove l'accesso)
+ ///
+ public async Task DisableModuleAsync(string code)
+ {
+ var module = await _context.AppModules
+ .Include(m => m.Subscription)
+ .FirstOrDefaultAsync(m => m.Code == code);
+
+ if (module == null)
+ throw new ArgumentException($"Modulo con codice '{code}' non trovato");
+
+ if (module.IsCore)
+ throw new InvalidOperationException("I moduli core non possono essere disattivati");
+
+ // Verifica se altri moduli dipendono da questo
+ var dependentModules = await GetDependentModulesAsync(code);
+ var activeDependents = dependentModules.Where(m => m.Subscription?.IsValid() ?? false).ToList();
+ if (activeDependents.Any())
+ throw new InvalidOperationException(
+ $"I seguenti moduli attivi dipendono da questo modulo: {string.Join(", ", activeDependents.Select(m => m.Name))}");
+
+ if (module.Subscription != null)
+ {
+ module.Subscription.IsEnabled = false;
+ module.Subscription.UpdatedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ }
+
+ InvalidateCache();
+ _logger.LogInformation("Modulo {ModuleCode} disattivato", code);
+ }
+
+ ///
+ /// Aggiorna i dettagli della subscription
+ ///
+ public async Task UpdateSubscriptionAsync(
+ string code,
+ SubscriptionType? subscriptionType = null,
+ DateTime? endDate = null,
+ bool? autoRenew = null,
+ string? notes = null)
+ {
+ var module = await _context.AppModules
+ .Include(m => m.Subscription)
+ .FirstOrDefaultAsync(m => m.Code == code);
+
+ if (module == null)
+ throw new ArgumentException($"Modulo con codice '{code}' non trovato");
+
+ if (module.Subscription == null)
+ throw new InvalidOperationException($"Il modulo '{code}' non ha una subscription attiva");
+
+ if (subscriptionType.HasValue)
+ module.Subscription.SubscriptionType = subscriptionType.Value;
+ if (endDate.HasValue)
+ module.Subscription.EndDate = endDate.Value;
+ if (autoRenew.HasValue)
+ module.Subscription.AutoRenew = autoRenew.Value;
+ if (notes != null)
+ module.Subscription.Notes = notes;
+
+ module.Subscription.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ InvalidateCache();
+
+ return module.Subscription;
+ }
+
+ ///
+ /// Rinnova una subscription esistente
+ ///
+ public async Task RenewSubscriptionAsync(string code, decimal? paidPrice = null)
+ {
+ var module = await _context.AppModules
+ .Include(m => m.Subscription)
+ .FirstOrDefaultAsync(m => m.Code == code);
+
+ if (module == null)
+ throw new ArgumentException($"Modulo con codice '{code}' non trovato");
+
+ if (module.Subscription == null)
+ throw new InvalidOperationException($"Il modulo '{code}' non ha una subscription da rinnovare");
+
+ var now = DateTime.UtcNow;
+ var currentEnd = module.Subscription.EndDate ?? now;
+ var newStart = currentEnd > now ? currentEnd : now;
+
+ var newEnd = module.Subscription.SubscriptionType switch
+ {
+ SubscriptionType.Monthly => newStart.AddMonths(1),
+ SubscriptionType.Annual => newStart.AddYears(1),
+ _ => newStart.AddYears(1) // Default to annual
+ };
+
+ module.Subscription.StartDate = newStart;
+ module.Subscription.EndDate = newEnd;
+ module.Subscription.LastRenewalDate = now;
+ module.Subscription.IsEnabled = true;
+ module.Subscription.PaidPrice = paidPrice ?? module.Subscription.PaidPrice;
+ module.Subscription.UpdatedAt = now;
+
+ await _context.SaveChangesAsync();
+ InvalidateCache();
+
+ _logger.LogInformation(
+ "Modulo {ModuleCode} rinnovato fino a {EndDate}",
+ code, newEnd);
+
+ return module.Subscription;
+ }
+
+ ///
+ /// Ottiene tutte le subscription
+ ///
+ public async Task> GetAllSubscriptionsAsync()
+ {
+ return await _context.ModuleSubscriptions
+ .Include(s => s.Module)
+ .OrderBy(s => s.Module.SortOrder)
+ .ToListAsync();
+ }
+
+ ///
+ /// Verifica e disattiva i moduli con subscription scaduta (per job schedulato)
+ ///
+ public async Task CheckExpiredSubscriptionsAsync()
+ {
+ var expiredSubscriptions = await _context.ModuleSubscriptions
+ .Include(s => s.Module)
+ .Where(s => s.IsEnabled &&
+ s.EndDate.HasValue &&
+ s.EndDate.Value < DateTime.UtcNow &&
+ !s.AutoRenew)
+ .ToListAsync();
+
+ foreach (var subscription in expiredSubscriptions)
+ {
+ subscription.IsEnabled = false;
+ subscription.UpdatedAt = DateTime.UtcNow;
+ _logger.LogWarning(
+ "Modulo {ModuleCode} disattivato per scadenza subscription",
+ subscription.Module.Code);
+ }
+
+ if (expiredSubscriptions.Any())
+ {
+ await _context.SaveChangesAsync();
+ InvalidateCache();
+ }
+
+ return expiredSubscriptions.Count;
+ }
+
+ ///
+ /// Ottiene i moduli in scadenza entro N giorni
+ ///
+ public async Task> GetExpiringModulesAsync(int daysThreshold = 30)
+ {
+ var thresholdDate = DateTime.UtcNow.AddDays(daysThreshold);
+
+ return await _context.AppModules
+ .Include(m => m.Subscription)
+ .Where(m => m.Subscription != null &&
+ m.Subscription.IsEnabled &&
+ m.Subscription.EndDate.HasValue &&
+ m.Subscription.EndDate.Value <= thresholdDate &&
+ m.Subscription.EndDate.Value > DateTime.UtcNow)
+ .OrderBy(m => m.Subscription!.EndDate)
+ .ToListAsync();
+ }
+
+ ///
+ /// Verifica le dipendenze mancanti per un modulo
+ ///
+ private async Task> CheckDependenciesAsync(AppModule module)
+ {
+ var dependencies = module.GetDependencies().ToList();
+ if (!dependencies.Any())
+ return new List();
+
+ var missingDeps = new List();
+
+ foreach (var depCode in dependencies)
+ {
+ if (!await IsModuleEnabledAsync(depCode))
+ {
+ var depModule = await GetModuleByCodeAsync(depCode);
+ missingDeps.Add(depModule?.Name ?? depCode);
+ }
+ }
+
+ return missingDeps;
+ }
+
+ ///
+ /// Ottiene i moduli che dipendono da un determinato modulo
+ ///
+ private async Task> GetDependentModulesAsync(string code)
+ {
+ var allModules = await GetAllModulesAsync();
+ return allModules
+ .Where(m => m.GetDependencies().Contains(code))
+ .ToList();
+ }
+
+ ///
+ /// Invalida la cache dei moduli
+ ///
+ public void InvalidateCache()
+ {
+ _cache.Remove(MODULES_CACHE_KEY);
+ _cache.Remove(ACTIVE_MODULES_CACHE_KEY);
+ _logger.LogDebug("Cache moduli invalidata");
+ }
+
+ ///
+ /// Inizializza i moduli di default se non esistono
+ ///
+ public async Task SeedDefaultModulesAsync()
+ {
+ if (await _context.AppModules.AnyAsync())
+ return;
+
+ var defaultModules = new List
+ {
+ new AppModule
+ {
+ Code = "warehouse",
+ Name = "Magazzino",
+ Description = "Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte",
+ Icon = "Warehouse",
+ BasePrice = 1200m,
+ MonthlyMultiplier = 1.2m,
+ SortOrder = 10,
+ IsCore = false,
+ RoutePath = "/warehouse",
+ IsAvailable = true,
+ CreatedAt = DateTime.UtcNow
+ },
+ new AppModule
+ {
+ Code = "purchases",
+ Name = "Acquisti",
+ Description = "Gestione ordini fornitori, DDT in entrata, fatture passive e analisi acquisti",
+ Icon = "ShoppingCart",
+ BasePrice = 1500m,
+ MonthlyMultiplier = 1.2m,
+ SortOrder = 20,
+ IsCore = false,
+ Dependencies = "warehouse",
+ RoutePath = "/purchases",
+ IsAvailable = true,
+ CreatedAt = DateTime.UtcNow
+ },
+ new AppModule
+ {
+ Code = "sales",
+ Name = "Vendite",
+ Description = "Gestione ordini clienti, DDT in uscita, fatture attive e analisi vendite",
+ Icon = "PointOfSale",
+ BasePrice = 1500m,
+ MonthlyMultiplier = 1.2m,
+ SortOrder = 30,
+ IsCore = false,
+ Dependencies = "warehouse",
+ RoutePath = "/sales",
+ IsAvailable = true,
+ CreatedAt = DateTime.UtcNow
+ },
+ new AppModule
+ {
+ Code = "production",
+ Name = "Produzione",
+ Description = "Cicli produttivi, distinte base, pianificazione MRP e controllo avanzamento",
+ Icon = "Precision Manufacturing",
+ BasePrice = 2500m,
+ MonthlyMultiplier = 1.2m,
+ SortOrder = 40,
+ IsCore = false,
+ Dependencies = "warehouse",
+ RoutePath = "/production",
+ IsAvailable = true,
+ CreatedAt = DateTime.UtcNow
+ },
+ new AppModule
+ {
+ Code = "quality",
+ Name = "Qualità",
+ Description = "Controlli qualità, gestione non conformità, certificazioni e audit",
+ Icon = "VerifiedUser",
+ BasePrice = 1800m,
+ MonthlyMultiplier = 1.2m,
+ SortOrder = 50,
+ IsCore = false,
+ RoutePath = "/quality",
+ IsAvailable = true,
+ CreatedAt = DateTime.UtcNow
+ }
+ };
+
+ _context.AppModules.AddRange(defaultModules);
+ await _context.SaveChangesAsync();
+
+ _logger.LogInformation("Seed {Count} moduli di default completato", defaultModules.Count);
+ }
+}
diff --git a/src/Apollinare.Domain/Entities/AppModule.cs b/src/Apollinare.Domain/Entities/AppModule.cs
new file mode 100644
index 0000000..55f3c4b
--- /dev/null
+++ b/src/Apollinare.Domain/Entities/AppModule.cs
@@ -0,0 +1,85 @@
+namespace Apollinare.Domain.Entities;
+
+///
+/// Rappresenta un modulo dell'applicazione (es. Magazzino, Acquisti, Vendite).
+/// I moduli possono essere attivati/disattivati per gestire licenze e funzionalità.
+///
+public class AppModule : BaseEntity
+{
+ ///
+ /// Codice univoco del modulo (es. "warehouse", "purchases", "sales")
+ ///
+ public required string Code { get; set; }
+
+ ///
+ /// Nome visualizzato del modulo (es. "Magazzino", "Acquisti")
+ ///
+ public required string Name { get; set; }
+
+ ///
+ /// Descrizione estesa delle funzionalità del modulo
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Nome dell'icona Material UI (es. "Warehouse", "ShoppingCart")
+ ///
+ public string? Icon { get; set; }
+
+ ///
+ /// Prezzo base annuale del modulo in EUR
+ ///
+ public decimal BasePrice { get; set; }
+
+ ///
+ /// Moltiplicatore per abbonamento mensile (es. 1.2 = 20% in più rispetto all'annuale/12)
+ ///
+ public decimal MonthlyMultiplier { get; set; } = 1.2m;
+
+ ///
+ /// Ordine di visualizzazione nel menu (più basso = prima)
+ ///
+ public int SortOrder { get; set; }
+
+ ///
+ /// Se true, il modulo fa parte del core e non può essere disattivato
+ ///
+ public bool IsCore { get; set; }
+
+ ///
+ /// Lista di codici modulo prerequisiti separati da virgola (es. "warehouse,purchases")
+ ///
+ public string? Dependencies { get; set; }
+
+ ///
+ /// Path base per le route frontend del modulo (es. "/warehouse")
+ ///
+ public string? RoutePath { get; set; }
+
+ ///
+ /// Se false, il modulo è nascosto e non disponibile per l'acquisto
+ ///
+ public bool IsAvailable { get; set; } = true;
+
+ // Navigation property
+ public ModuleSubscription? Subscription { get; set; }
+
+ ///
+ /// Restituisce la lista dei codici modulo prerequisiti
+ ///
+ public IEnumerable GetDependencies()
+ {
+ if (string.IsNullOrWhiteSpace(Dependencies))
+ return Enumerable.Empty();
+
+ return Dependencies.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+
+ ///
+ /// Calcola il prezzo mensile basato su BasePrice e MonthlyMultiplier
+ ///
+ public decimal GetMonthlyPrice()
+ {
+ return Math.Round((BasePrice / 12) * MonthlyMultiplier, 2);
+ }
+}
diff --git a/src/Apollinare.Domain/Entities/ModuleSubscription.cs b/src/Apollinare.Domain/Entities/ModuleSubscription.cs
new file mode 100644
index 0000000..9047865
--- /dev/null
+++ b/src/Apollinare.Domain/Entities/ModuleSubscription.cs
@@ -0,0 +1,108 @@
+namespace Apollinare.Domain.Entities;
+
+///
+/// Tipo di abbonamento per un modulo
+///
+public enum SubscriptionType
+{
+ /// Nessun abbonamento attivo
+ None = 0,
+ /// Abbonamento mensile
+ Monthly = 1,
+ /// Abbonamento annuale
+ Annual = 2
+}
+
+///
+/// Rappresenta lo stato di abbonamento/attivazione di un modulo per questa istanza dell'applicazione.
+/// Ogni ModuleSubscription è collegata 1:1 con un AppModule.
+///
+public class ModuleSubscription : BaseEntity
+{
+ ///
+ /// ID del modulo associato
+ ///
+ public int ModuleId { get; set; }
+
+ ///
+ /// Se true, il modulo è attualmente attivo e accessibile
+ ///
+ public bool IsEnabled { get; set; }
+
+ ///
+ /// Tipo di abbonamento corrente
+ ///
+ public SubscriptionType SubscriptionType { get; set; } = SubscriptionType.None;
+
+ ///
+ /// Data di inizio dell'abbonamento corrente
+ ///
+ public DateTime? StartDate { get; set; }
+
+ ///
+ /// Data di scadenza dell'abbonamento (null = nessuna scadenza, es. licenza perpetua)
+ ///
+ public DateTime? EndDate { get; set; }
+
+ ///
+ /// Se true, l'abbonamento si rinnova automaticamente alla scadenza
+ ///
+ public bool AutoRenew { get; set; }
+
+ ///
+ /// Note aggiuntive sull'abbonamento (es. codice ordine, riferimento contratto)
+ ///
+ public string? Notes { get; set; }
+
+ ///
+ /// Data dell'ultimo rinnovo effettuato
+ ///
+ public DateTime? LastRenewalDate { get; set; }
+
+ ///
+ /// Prezzo pagato per l'abbonamento corrente (può differire da BasePrice per sconti)
+ ///
+ public decimal? PaidPrice { get; set; }
+
+ // Navigation property
+ public AppModule Module { get; set; } = null!;
+
+ ///
+ /// Verifica se l'abbonamento è attualmente valido (attivo e non scaduto)
+ ///
+ public bool IsValid()
+ {
+ if (!IsEnabled)
+ return false;
+
+ // Se non c'è data di scadenza, è valido (licenza perpetua o core module)
+ if (!EndDate.HasValue)
+ return true;
+
+ return EndDate.Value >= DateTime.UtcNow;
+ }
+
+ ///
+ /// Calcola i giorni rimanenti alla scadenza (null se nessuna scadenza)
+ ///
+ public int? GetDaysRemaining()
+ {
+ if (!EndDate.HasValue)
+ return null;
+
+ var remaining = (EndDate.Value - DateTime.UtcNow).Days;
+ return remaining < 0 ? 0 : remaining;
+ }
+
+ ///
+ /// Verifica se l'abbonamento sta per scadere (entro i prossimi N giorni)
+ ///
+ public bool IsExpiringSoon(int daysThreshold = 30)
+ {
+ if (!EndDate.HasValue)
+ return false;
+
+ var daysRemaining = GetDaysRemaining();
+ return daysRemaining.HasValue && daysRemaining.Value <= daysThreshold && daysRemaining.Value > 0;
+ }
+}
diff --git a/src/Apollinare.Infrastructure/Data/AppollinareDbContext.cs b/src/Apollinare.Infrastructure/Data/AppollinareDbContext.cs
index 9cb948c..0cbee2f 100644
--- a/src/Apollinare.Infrastructure/Data/AppollinareDbContext.cs
+++ b/src/Apollinare.Infrastructure/Data/AppollinareDbContext.cs
@@ -36,6 +36,10 @@ public class AppollinareDbContext : DbContext
public DbSet ReportImages => Set();
public DbSet VirtualDatasets => Set();
+ // Module system entities
+ public DbSet AppModules => Set();
+ public DbSet ModuleSubscriptions => Set();
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -225,5 +229,32 @@ public class AppollinareDbContext : DbContext
entity.HasIndex(e => e.Nome).IsUnique();
entity.HasIndex(e => e.Categoria);
});
+
+ // AppModule
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasIndex(e => e.Code).IsUnique();
+ entity.HasIndex(e => e.SortOrder);
+
+ entity.Property(e => e.BasePrice)
+ .HasPrecision(18, 2);
+
+ entity.Property(e => e.MonthlyMultiplier)
+ .HasPrecision(5, 2);
+ });
+
+ // ModuleSubscription
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasIndex(e => e.ModuleId).IsUnique();
+
+ entity.Property(e => e.PaidPrice)
+ .HasPrecision(18, 2);
+
+ entity.HasOne(e => e.Module)
+ .WithOne(m => m.Subscription)
+ .HasForeignKey(e => e.ModuleId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
}
}