From bb2d0729e1d81e7d7e97ffc7968e357f65b1d2ae Mon Sep 17 00:00:00 2001 From: dnviti Date: Sat, 29 Nov 2025 13:30:28 +0100 Subject: [PATCH] - --- CLAUDE.md | 528 +++++++++++++++++- frontend/src/App.tsx | 67 ++- frontend/src/components/Layout.tsx | 2 + frontend/src/components/ModuleGuard.tsx | 75 +++ frontend/src/contexts/ModuleContext.tsx | 182 ++++++ frontend/src/pages/ModulePurchasePage.tsx | 386 +++++++++++++ frontend/src/pages/ModulesAdminPage.tsx | 515 +++++++++++++++++ frontend/src/services/moduleService.ts | 128 +++++ frontend/src/types/module.ts | 174 ++++++ src/Apollinare.API/Apollinare.API.csproj | 4 + .../Controllers/ModulesController.cs | 352 ++++++++++++ src/Apollinare.API/Program.cs | 9 + src/Apollinare.API/Services/ModuleService.cs | 493 ++++++++++++++++ src/Apollinare.Domain/Entities/AppModule.cs | 85 +++ .../Entities/ModuleSubscription.cs | 108 ++++ .../Data/AppollinareDbContext.cs | 31 + 16 files changed, 3102 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/ModuleGuard.tsx create mode 100644 frontend/src/contexts/ModuleContext.tsx create mode 100644 frontend/src/pages/ModulePurchasePage.tsx create mode 100644 frontend/src/pages/ModulesAdminPage.tsx create mode 100644 frontend/src/services/moduleService.ts create mode 100644 frontend/src/types/module.ts create mode 100644 src/Apollinare.API/Controllers/ModulesController.cs create mode 100644 src/Apollinare.API/Services/ModuleService.cs create mode 100644 src/Apollinare.Domain/Entities/AppModule.cs create mode 100644 src/Apollinare.Domain/Entities/ModuleSubscription.cs 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. + + + + ); + } + + // 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 */} + + + + 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 */} + + + {/* 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 + + + + + + + + + {/* 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 */} +

setSelectedModule(null)} + maxWidth="sm" + fullWidth + > + {selectedModule && ( + <> + + + {getModuleIcon(selectedModule.icon)} + {selectedModule.name} + + + + {selectedModule.description} + + + + + + + Prezzo annuale + + + {formatPrice(selectedModule.basePrice)} + + + + + Prezzo mensile + + + {formatPrice(selectedModule.monthlyPrice)} + + + + + {selectedModule.dependencies.length > 0 && ( + + + Dipendenze + + + {selectedModule.dependencies.map((dep) => ( + + ))} + + + )} + + {selectedModule.subscription && ( + <> + + + Dettagli Subscription + + + + + Tipo + + + {selectedModule.subscription.subscriptionTypeName} + + + + + Stato + + + + + + + + Data inizio + + + {formatDate(selectedModule.subscription.startDate)} + + + + + Data scadenza + + + {formatDate(selectedModule.subscription.endDate)} + + + + + Giorni rimanenti + + + {getDaysRemainingText( + selectedModule.subscription.daysRemaining, + )} + + + + + Rinnovo automatico + + + {selectedModule.subscription.autoRenew ? "Sì" : "No"} + + + + + )} + + + + + + )} + + + {/* Dialog conferma disattivazione */} + setConfirmDisable(null)} + maxWidth="xs" + fullWidth + > + Conferma disattivazione + + + Sei sicuro di voler disattivare il modulo{" "} + + {modules.find((m) => m.code === confirmDisable)?.name} + + ? + + + I dati inseriti rimarranno nel sistema ma non saranno più + accessibili fino alla riattivazione. + + {disableMutation.isError && ( + + {(disableMutation.error as Error)?.message || + "Errore durante la 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); + }); } }