This commit is contained in:
2025-11-29 13:30:28 +01:00
parent 824a761bf6
commit bb2d0729e1
16 changed files with 3102 additions and 37 deletions

528
CLAUDE.md
View File

@@ -46,12 +46,52 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
## Quick Start - Session Recovery ## 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 **Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
**Lavoro completato nell'ultima sessione:** **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 - **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 - **Obiettivo:** I pannelli del report designer devono poter essere trascinati tra sidebar sinistra e destra, con ridimensionamento orizzontale a livello sidebar
- **Architettura implementata:** - **Architettura implementata:**
@@ -274,15 +314,23 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
- **PDF Generator:** `/src/Apollinare.API/Services/Reports/ReportGeneratorService.cs` - **PDF Generator:** `/src/Apollinare.API/Services/Reports/ReportGeneratorService.cs`
- **Page Navigator:** `/frontend/src/components/reportEditor/PageNavigator.tsx` - **Page Navigator:** `/frontend/src/components/reportEditor/PageNavigator.tsx`
**Prossimi task prioritari (Report System):** **Prossimi task prioritari:**
1. [x] ~~**CRITICO: Posizionamento assoluto PDF**~~ - COMPLETATO **MODULI BUSINESS (PRIORITÀ ALTA):**
2. [x] ~~Implementare caricamento immagini reali~~ - COMPLETATO
3. [x] ~~**FIX: Rotazione oggetti nel PDF**~~ - COMPLETATO 1. [ ] **Implementare modulo Magazzino (warehouse)** - Base per tutti gli altri
4. [x] ~~**Gestione Multi-Pagina**~~ - COMPLETATO 2. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino
5. [ ] Aggiungere rendering tabelle dinamiche per collection 3. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino
6. [ ] Gestire sezioni header/footer ripetute su ogni pagina 4. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino
7. [ ] UI per relazioni tra dataset multipli 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!):** **Comandi utili (usa il Makefile!):**
@@ -1345,3 +1393,465 @@ Migration già applicata per SQLite.
``` ```
Menu aggiunto in `Layout.tsx` sotto "Report" con icona PrintIcon. 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
<Route
path="/warehouse/*"
element={
<ModuleGuard moduleCode="warehouse">
<WarehouseRoutes />
</ModuleGuard>
}
/>
// Pagina acquisto modulo
<Route path="/modules/purchase/:code" element={<ModulePurchasePage />} />
```
### Logica Backend
**ModuleService:**
```csharp
public class ModuleService
{
// Verifica se modulo è attivo (usato da altri servizi)
public async Task<bool> IsModuleEnabledAsync(string code);
// Verifica subscription valida (non scaduta)
public async Task<bool> 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<string> CustomFields { get; set; } = new();
}
```
**Esempio hook point per estensioni:**
```csharp
// IWarehouseService.cs
public interface IWarehouseService
{
// Metodi standard
Task<Article> GetArticleAsync(int id);
Task<Article> CreateArticleAsync(ArticleDto dto);
// Hook points per customizzazione
event Func<Article, Task>? OnArticleCreated;
event Func<StockMovement, Task<bool>>? OnBeforeStockMovement;
event Func<StockMovement, Task>? 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

View File

@@ -17,8 +17,11 @@ import RisorsePage from "./pages/RisorsePage";
import CalendarioPage from "./pages/CalendarioPage"; import CalendarioPage from "./pages/CalendarioPage";
import ReportTemplatesPage from "./pages/ReportTemplatesPage"; import ReportTemplatesPage from "./pages/ReportTemplatesPage";
import ReportEditorPage from "./pages/ReportEditorPage"; import ReportEditorPage from "./pages/ReportEditorPage";
import ModulesAdminPage from "./pages/ModulesAdminPage";
import ModulePurchasePage from "./pages/ModulePurchasePage";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates"; import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext"; import { CollaborationProvider } from "./contexts/CollaborationContext";
import { ModuleProvider } from "./contexts/ModuleContext";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -60,34 +63,42 @@ function App() {
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<CssBaseline /> <CssBaseline />
<BrowserRouter> <BrowserRouter>
<CollaborationProvider> <ModuleProvider>
<RealTimeProvider> <CollaborationProvider>
<Routes> <RealTimeProvider>
<Route path="/" element={<Layout />}> <Routes>
<Route index element={<Dashboard />} /> <Route path="/" element={<Layout />}>
<Route path="calendario" element={<CalendarioPage />} /> <Route index element={<Dashboard />} />
<Route path="eventi" element={<EventiPage />} /> <Route path="calendario" element={<CalendarioPage />} />
<Route path="eventi/:id" element={<EventoDetailPage />} /> <Route path="eventi" element={<EventiPage />} />
<Route path="clienti" element={<ClientiPage />} /> <Route path="eventi/:id" element={<EventoDetailPage />} />
<Route path="location" element={<LocationPage />} /> <Route path="clienti" element={<ClientiPage />} />
<Route path="articoli" element={<ArticoliPage />} /> <Route path="location" element={<LocationPage />} />
<Route path="risorse" element={<RisorsePage />} /> <Route path="articoli" element={<ArticoliPage />} />
<Route <Route path="risorse" element={<RisorsePage />} />
path="report-templates" <Route
element={<ReportTemplatesPage />} path="report-templates"
/> element={<ReportTemplatesPage />}
<Route />
path="report-editor" <Route
element={<ReportEditorPage />} path="report-editor"
/> element={<ReportEditorPage />}
<Route />
path="report-editor/:id" <Route
element={<ReportEditorPage />} path="report-editor/:id"
/> element={<ReportEditorPage />}
</Route> />
</Routes> {/* Moduli */}
</RealTimeProvider> <Route path="modules" element={<ModulesAdminPage />} />
</CollaborationProvider> <Route
path="modules/purchase/:code"
element={<ModulePurchasePage />}
/>
</Route>
</Routes>
</RealTimeProvider>
</CollaborationProvider>
</ModuleProvider>
</BrowserRouter> </BrowserRouter>
</LocalizationProvider> </LocalizationProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -27,6 +27,7 @@ import {
CalendarMonth as CalendarIcon, CalendarMonth as CalendarIcon,
Print as PrintIcon, Print as PrintIcon,
Close as CloseIcon, Close as CloseIcon,
Extension as ModulesIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import CollaborationIndicator from "./collaboration/CollaborationIndicator"; import CollaborationIndicator from "./collaboration/CollaborationIndicator";
@@ -42,6 +43,7 @@ const menuItems = [
{ text: "Articoli", icon: <InventoryIcon />, path: "/articoli" }, { text: "Articoli", icon: <InventoryIcon />, path: "/articoli" },
{ text: "Risorse", icon: <PersonIcon />, path: "/risorse" }, { text: "Risorse", icon: <PersonIcon />, path: "/risorse" },
{ text: "Report", icon: <PrintIcon />, path: "/report-templates" }, { text: "Report", icon: <PrintIcon />, path: "/report-templates" },
{ text: "Moduli", icon: <ModulesIcon />, path: "/modules" },
]; ];
export default function Layout() { export default function Layout() {

View File

@@ -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 (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "50vh",
gap: 2,
}}
>
<Typography variant="h5" color="error">
Modulo non trovato
</Typography>
<Typography color="text.secondary">
Il modulo "{moduleCode}" non esiste.
</Typography>
</Box>
);
}
// Se il modulo è abilitato, mostra il contenuto
if (isEnabled) {
return <>{children}</>;
}
// Modulo non abilitato: redirect alla pagina di acquisto
return (
<Navigate
to={`/modules/purchase/${moduleCode}`}
state={{ from: location.pathname }}
replace
/>
);
}
/**
* HOC per proteggere un componente con ModuleGuard
*/
export function withModuleGuard<P extends object>(
WrappedComponent: React.ComponentType<P>,
moduleCode: string,
) {
return function ModuleGuardedComponent(props: P) {
return (
<ModuleGuard moduleCode={moduleCode}>
<WrappedComponent {...props} />
</ModuleGuard>
);
};
}
export default ModuleGuard;

View File

@@ -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<SubscriptionDto>;
/** Disattiva un modulo */
disableModule: (code: string) => Promise<void>;
/** Ricarica i dati dei moduli */
refreshModules: () => Promise<void>;
}
const ModuleContext = createContext<ModuleContextValue | null>(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<SubscriptionDto> => {
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<void> => {
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<void> => {
await queryClient.invalidateQueries({ queryKey: ["modules"] });
}, [queryClient]);
const value: ModuleContextValue = {
modules,
activeModules,
activeModuleCodes,
isLoading: isLoadingModules || isLoadingActive,
error: modulesError || activeError,
isModuleEnabled,
getModule,
enableModule,
disableModule,
refreshModules,
};
return (
<ModuleContext.Provider value={value}>{children}</ModuleContext.Provider>
);
}
// ==================== 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);
}

View File

@@ -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>(
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 (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="h5" color="error" gutterBottom>
Modulo non trovato
</Typography>
<Typography color="text.secondary" paragraph>
Il modulo richiesto non esiste.
</Typography>
<Button
variant="contained"
startIcon={<BackIcon />}
onClick={() => navigate("/")}
>
Torna alla Home
</Button>
</Box>
);
}
// 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 (
<Box sx={{ p: 3, maxWidth: 800, mx: "auto" }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Button
startIcon={<BackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 2 }}
>
Indietro
</Button>
<Typography variant="h4" gutterBottom>
Attiva Modulo
</Typography>
<Typography color="text.secondary">
Scegli il piano di abbonamento per il modulo {module.name}
</Typography>
</Box>
{/* Alert dipendenze mancanti */}
{missingDependencies.length > 0 && (
<Alert severity="warning" sx={{ mb: 3 }} icon={<WarningIcon />}>
<Typography variant="subtitle2" gutterBottom>
Questo modulo richiede i seguenti moduli che non sono attivi:
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
{missingDependencies.map((dep) => (
<Chip
key={dep}
label={dep}
size="small"
color="warning"
onClick={() => navigate(`/modules/purchase/${dep}`)}
/>
))}
</Box>
</Alert>
)}
{/* Card principale */}
<Card elevation={3}>
<CardContent sx={{ p: 4 }}>
{/* Info modulo */}
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom>
{module.name}
</Typography>
<Typography color="text.secondary" paragraph>
{module.description}
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
{/* Selezione tipo abbonamento */}
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="medium">
Tipo di abbonamento
</Typography>
<ToggleButtonGroup
value={subscriptionType}
exclusive
onChange={(_, value) => value && setSubscriptionType(value)}
fullWidth
sx={{ mb: 2 }}
>
<ToggleButton value={SubscriptionType.Monthly}>
<Box sx={{ py: 1, textAlign: "center" }}>
<MonthlyIcon sx={{ mb: 0.5 }} />
<Typography variant="body2" display="block">
Mensile
</Typography>
<Typography variant="h6" color="primary">
{formatPrice(module.monthlyPrice)}
<Typography
component="span"
variant="body2"
color="text.secondary"
>
/mese
</Typography>
</Typography>
</Box>
</ToggleButton>
<ToggleButton value={SubscriptionType.Annual}>
<Box sx={{ py: 1, textAlign: "center" }}>
<AnnualIcon sx={{ mb: 0.5 }} />
<Typography variant="body2" display="block">
Annuale
</Typography>
<Typography variant="h6" color="primary">
{formatPrice(module.basePrice)}
<Typography
component="span"
variant="body2"
color="text.secondary"
>
/anno
</Typography>
</Typography>
{savingsPercent > 0 && (
<Chip
label={`Risparmi ${savingsPercent}%`}
size="small"
color="success"
sx={{ mt: 0.5 }}
/>
)}
</Box>
</ToggleButton>
</ToggleButtonGroup>
{/* Opzione auto-rinnovo */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ToggleButton
value="autoRenew"
selected={autoRenew}
onChange={() => setAutoRenew(!autoRenew)}
size="small"
>
{autoRenew ? <CheckIcon /> : null}
</ToggleButton>
<Typography variant="body2">
Rinnovo automatico alla scadenza
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Riepilogo */}
<Paper
variant="outlined"
sx={{ p: 2, mb: 3, bgcolor: "action.hover" }}
>
<Typography variant="subtitle2" gutterBottom>
Riepilogo ordine
</Typography>
<Box
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
>
<Typography>Modulo {module.name}</Typography>
<Typography>{formatPrice(price)}</Typography>
</Box>
<Box
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
>
<Typography color="text.secondary">
Abbonamento{" "}
{getSubscriptionTypeName(subscriptionType).toLowerCase()}
</Typography>
<Typography color="text.secondary">{priceLabel}</Typography>
</Box>
<Divider sx={{ my: 1 }} />
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="h6">Totale</Typography>
<Typography variant="h6" color="primary">
{formatPrice(price)}
{priceLabel}
</Typography>
</Box>
</Paper>
{/* Errore */}
{enableMutation.isError && (
<Alert severity="error" sx={{ mb: 3 }}>
{(enableMutation.error as Error)?.message ||
"Errore durante l'attivazione del modulo"}
</Alert>
)}
{/* Pulsante attivazione */}
<Button
variant="contained"
size="large"
fullWidth
startIcon={
enableMutation.isPending ? (
<CircularProgress size={20} color="inherit" />
) : (
<CartIcon />
)
}
onClick={() => enableMutation.mutate()}
disabled={
enableMutation.isPending || missingDependencies.length > 0
}
>
{enableMutation.isPending
? "Attivazione in corso..."
: "Attiva Modulo"}
</Button>
{/* Note */}
<Typography
variant="caption"
color="text.secondary"
display="block"
textAlign="center"
sx={{ mt: 2 }}
>
Potrai disattivare il modulo in qualsiasi momento dalle
impostazioni. I dati inseriti rimarranno disponibili.
</Typography>
</CardContent>
</Card>
{/* Funzionalità incluse */}
<Card sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Funzionalità incluse
</Typography>
<List dense>
{getModuleFeatures(module.code).map((feature, index) => (
<ListItem key={index}>
<ListItemIcon sx={{ minWidth: 36 }}>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={feature} />
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
);
}
// Helper per ottenere le funzionalità di un modulo
function getModuleFeatures(code: string): string[] {
const features: Record<string, string[]> = {
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"];
}

View File

@@ -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<ModuleDto | null>(null);
const [confirmDisable, setConfirmDisable] = useState<string | null>(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 <Icons.Extension />;
const IconComponent = (Icons as Record<string, React.ComponentType>)[
iconName.replace(/\s+/g, "")
];
return IconComponent ? <IconComponent /> : <Icons.Extension />;
};
if (isLoading) {
return (
<Box sx={{ p: 3 }}>
<LinearProgress />
</Box>
);
}
return (
<Box sx={{ p: 3 }}>
{/* Header */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 3,
}}
>
<Box>
<Typography variant="h4" gutterBottom>
Gestione Moduli
</Typography>
<Typography color="text.secondary">
Configura i moduli attivi e gestisci le subscription
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
startIcon={<ScheduleIcon />}
onClick={() => checkExpiredMutation.mutate()}
disabled={checkExpiredMutation.isPending}
>
Controlla Scadenze
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => refreshModules()}
>
Aggiorna
</Button>
</Box>
</Box>
{/* Alert moduli in scadenza */}
{expiringModules.length > 0 && (
<Alert severity="warning" sx={{ mb: 3 }} icon={<WarningIcon />}>
<Typography variant="subtitle2" gutterBottom>
{expiringModules.length} modulo/i in scadenza nei prossimi 30
giorni:
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
{expiringModules.map((m) => (
<Chip
key={m.code}
label={`${m.name} (${getDaysRemainingText(m.subscription?.daysRemaining)})`}
size="small"
color="warning"
/>
))}
</Box>
</Alert>
)}
{/* Griglia moduli */}
<Grid container spacing={3}>
{modules.map((module) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={module.code}>
<ModuleCard
module={module}
onToggle={() => {
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}
/>
</Grid>
))}
</Grid>
{/* Dialog dettagli modulo */}
<Dialog
open={!!selectedModule}
onClose={() => setSelectedModule(null)}
maxWidth="sm"
fullWidth
>
{selectedModule && (
<>
<DialogTitle>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{getModuleIcon(selectedModule.icon)}
{selectedModule.name}
</Box>
</DialogTitle>
<DialogContent dividers>
<Typography paragraph>{selectedModule.description}</Typography>
<Divider sx={{ my: 2 }} />
<Grid container spacing={2}>
<Grid size={{ xs: 6 }}>
<Typography variant="caption" color="text.secondary">
Prezzo annuale
</Typography>
<Typography variant="h6">
{formatPrice(selectedModule.basePrice)}
</Typography>
</Grid>
<Grid size={{ xs: 6 }}>
<Typography variant="caption" color="text.secondary">
Prezzo mensile
</Typography>
<Typography variant="h6">
{formatPrice(selectedModule.monthlyPrice)}
</Typography>
</Grid>
</Grid>
{selectedModule.dependencies.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
Dipendenze
</Typography>
<Box sx={{ display: "flex", gap: 0.5, mt: 0.5 }}>
{selectedModule.dependencies.map((dep) => (
<Chip key={dep} label={dep} size="small" />
))}
</Box>
</Box>
)}
{selectedModule.subscription && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Dettagli Subscription
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 6 }}>
<Typography variant="caption" color="text.secondary">
Tipo
</Typography>
<Typography>
{selectedModule.subscription.subscriptionTypeName}
</Typography>
</Grid>
<Grid size={{ xs: 6 }}>
<Typography variant="caption" color="text.secondary">
Stato
</Typography>
<Typography>
<Chip
label={getSubscriptionStatusText(
selectedModule.subscription,
)}
size="small"
color={getSubscriptionStatusColor(
selectedModule.subscription,
)}
/>
</Typography>
</Grid>
<Grid size={{ xs: 6 }}>
<Typography variant="caption" color="text.secondary">
Data inizio
</Typography>
<Typography>
{formatDate(selectedModule.subscription.startDate)}
</Typography>
</Grid>
<Grid size={{ xs: 6 }}>
<Typography variant="caption" color="text.secondary">
Data scadenza
</Typography>
<Typography>
{formatDate(selectedModule.subscription.endDate)}
</Typography>
</Grid>
<Grid size={{ xs: 6 }}>
<Typography variant="caption" color="text.secondary">
Giorni rimanenti
</Typography>
<Typography>
{getDaysRemainingText(
selectedModule.subscription.daysRemaining,
)}
</Typography>
</Grid>
<Grid size={{ xs: 6 }}>
<Typography variant="caption" color="text.secondary">
Rinnovo automatico
</Typography>
<Typography>
{selectedModule.subscription.autoRenew ? "Sì" : "No"}
</Typography>
</Grid>
</Grid>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setSelectedModule(null)}>Chiudi</Button>
</DialogActions>
</>
)}
</Dialog>
{/* Dialog conferma disattivazione */}
<Dialog
open={!!confirmDisable}
onClose={() => setConfirmDisable(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Conferma disattivazione</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler disattivare il modulo{" "}
<strong>
{modules.find((m) => m.code === confirmDisable)?.name}
</strong>
?
</Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}>
I dati inseriti rimarranno nel sistema ma non saranno più
accessibili fino alla riattivazione.
</Typography>
{disableMutation.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{(disableMutation.error as Error)?.message ||
"Errore durante la disattivazione"}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDisable(null)}>Annulla</Button>
<Button
color="error"
variant="contained"
onClick={() =>
confirmDisable && disableMutation.mutate(confirmDisable)
}
disabled={disableMutation.isPending}
startIcon={
disableMutation.isPending ? (
<CircularProgress size={16} color="inherit" />
) : null
}
>
Disattiva
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// 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 (
<Card
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
borderLeft: 4,
borderColor: module.isEnabled
? statusColor === "success"
? "success.main"
: statusColor === "warning"
? "warning.main"
: "error.main"
: "grey.300",
opacity: module.isEnabled ? 1 : 0.7,
}}
>
<CardContent sx={{ flexGrow: 1 }}>
{/* Header */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 2,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ color: module.isEnabled ? "primary.main" : "grey.500" }}>
{getIcon(module.icon)}
</Box>
<Typography variant="h6">{module.name}</Typography>
</Box>
<Box>
{module.isCore ? (
<Chip label="Core" size="small" color="info" />
) : (
<Chip label={statusText} size="small" color={statusColor} />
)}
</Box>
</Box>
{/* Descrizione */}
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 2,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{module.description}
</Typography>
{/* Info subscription */}
{module.subscription && module.isEnabled && (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary">
Scadenza: {formatDate(module.subscription.endDate)}
{module.subscription.daysRemaining !== undefined &&
module.subscription.daysRemaining <= 30 && (
<Chip
label={getDaysRemainingText(
module.subscription.daysRemaining,
)}
size="small"
color={
module.subscription.daysRemaining <= 7
? "error"
: "warning"
}
sx={{ ml: 1 }}
/>
)}
</Typography>
</Box>
)}
{/* Prezzo */}
<Typography variant="body2">
{formatPrice(module.basePrice)}
<Typography component="span" variant="caption" color="text.secondary">
/anno
</Typography>
</Typography>
</CardContent>
{/* Actions */}
<Box
sx={{
px: 2,
pb: 2,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box>
<Tooltip title="Dettagli">
<IconButton size="small" onClick={onInfo}>
<InfoIcon />
</IconButton>
</Tooltip>
{module.isEnabled &&
!module.isCore &&
module.subscription?.isExpiringSoon && (
<Tooltip title="Rinnova">
<IconButton
size="small"
color="warning"
onClick={onRenew}
disabled={isRenewing}
>
{isRenewing ? <CircularProgress size={20} /> : <RenewIcon />}
</IconButton>
</Tooltip>
)}
</Box>
{!module.isCore && (
<FormControlLabel
control={
<Switch
checked={module.isEnabled}
onChange={onToggle}
color={module.isEnabled ? "success" : "default"}
/>
}
label={module.isEnabled ? "Attivo" : "Disattivo"}
labelPlacement="start"
/>
)}
</Box>
</Card>
);
}

View File

@@ -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<ModuleDto[]> => {
const response = await api.get("/modules");
return response.data;
},
/**
* Ottiene solo i moduli attivi (per costruzione menu)
*/
getActive: async (): Promise<ModuleDto[]> => {
const response = await api.get("/modules/active");
return response.data;
},
/**
* Ottiene un modulo specifico per codice
*/
getByCode: async (code: string): Promise<ModuleDto> => {
const response = await api.get(`/modules/${code}`);
return response.data;
},
/**
* Verifica se un modulo è abilitato
*/
isEnabled: async (code: string): Promise<ModuleStatusDto> => {
const response = await api.get(`/modules/${code}/enabled`);
return response.data;
},
/**
* Attiva un modulo
*/
enable: async (code: string, request: EnableModuleRequest): Promise<SubscriptionDto> => {
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<SubscriptionDto[]> => {
const response = await api.get("/modules/subscriptions");
return response.data;
},
/**
* Aggiorna la subscription di un modulo
*/
updateSubscription: async (
code: string,
request: UpdateSubscriptionRequest
): Promise<SubscriptionDto> => {
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<SubscriptionDto> => {
const response = await api.post(`/modules/${code}/subscription/renew`, request || {});
return response.data;
},
/**
* Ottiene i moduli in scadenza
*/
getExpiring: async (daysThreshold: number = 30): Promise<ModuleDto[]> => {
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;

View File

@@ -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<string, string> = {
warehouse: "Warehouse",
purchases: "ShoppingCart",
sales: "PointOfSale",
production: "PrecisionManufacturing",
quality: "VerifiedUser",
};

View File

@@ -8,6 +8,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="QuestPDF" Version="2024.12.2" /> <PackageReference Include="QuestPDF" Version="2024.12.2" />
<PackageReference Include="SkiaSharp" Version="3.116.1" /> <PackageReference Include="SkiaSharp" Version="3.116.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />

View File

@@ -0,0 +1,352 @@
using Apollinare.API.Services;
using Apollinare.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Controllers;
/// <summary>
/// Controller per la gestione dei moduli applicativi e delle subscription
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class ModulesController : ControllerBase
{
private readonly ModuleService _moduleService;
private readonly ILogger<ModulesController> _logger;
public ModulesController(ModuleService moduleService, ILogger<ModulesController> logger)
{
_moduleService = moduleService;
_logger = logger;
}
/// <summary>
/// Ottiene tutti i moduli disponibili con stato subscription
/// </summary>
[HttpGet]
public async Task<ActionResult<List<ModuleDto>>> GetAllModules()
{
var modules = await _moduleService.GetAllModulesAsync();
return Ok(modules.Select(MapToDto).ToList());
}
/// <summary>
/// Ottiene solo i moduli attivi (per costruzione menu)
/// </summary>
[HttpGet("active")]
public async Task<ActionResult<List<ModuleDto>>> GetActiveModules()
{
var modules = await _moduleService.GetActiveModulesAsync();
return Ok(modules.Select(MapToDto).ToList());
}
/// <summary>
/// Ottiene un modulo specifico per codice
/// </summary>
[HttpGet("{code}")]
public async Task<ActionResult<ModuleDto>> GetModule(string code)
{
var module = await _moduleService.GetModuleByCodeAsync(code);
if (module == null)
return NotFound(new { message = $"Modulo '{code}' non trovato" });
return Ok(MapToDto(module));
}
/// <summary>
/// Verifica se un modulo è abilitato
/// </summary>
[HttpGet("{code}/enabled")]
public async Task<ActionResult<ModuleStatusDto>> 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
});
}
/// <summary>
/// Attiva un modulo
/// </summary>
[HttpPut("{code}/enable")]
public async Task<ActionResult<SubscriptionDto>> 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 });
}
}
/// <summary>
/// Disattiva un modulo
/// </summary>
[HttpPut("{code}/disable")]
public async Task<ActionResult> 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 });
}
}
/// <summary>
/// Ottiene tutte le subscription
/// </summary>
[HttpGet("subscriptions")]
public async Task<ActionResult<List<SubscriptionDto>>> GetAllSubscriptions()
{
var subscriptions = await _moduleService.GetAllSubscriptionsAsync();
return Ok(subscriptions.Select(MapSubscriptionToDto).ToList());
}
/// <summary>
/// Aggiorna la subscription di un modulo
/// </summary>
[HttpPut("{code}/subscription")]
public async Task<ActionResult<SubscriptionDto>> 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 });
}
}
/// <summary>
/// Rinnova la subscription di un modulo
/// </summary>
[HttpPost("{code}/subscription/renew")]
public async Task<ActionResult<SubscriptionDto>> 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 });
}
}
/// <summary>
/// Ottiene i moduli in scadenza
/// </summary>
[HttpGet("expiring")]
public async Task<ActionResult<List<ModuleDto>>> GetExpiringModules([FromQuery] int daysThreshold = 30)
{
var modules = await _moduleService.GetExpiringModulesAsync(daysThreshold);
return Ok(modules.Select(MapToDto).ToList());
}
/// <summary>
/// Inizializza i moduli di default (per setup iniziale)
/// </summary>
[HttpPost("seed")]
public async Task<ActionResult> SeedDefaultModules()
{
await _moduleService.SeedDefaultModulesAsync();
return Ok(new { message = "Moduli di default inizializzati" });
}
/// <summary>
/// Forza il controllo delle subscription scadute
/// </summary>
[HttpPost("check-expired")]
public async Task<ActionResult> CheckExpiredSubscriptions()
{
var count = await _moduleService.CheckExpiredSubscriptionsAsync();
return Ok(new { message = $"Controllate le subscription, {count} moduli disattivati per scadenza" });
}
/// <summary>
/// Invalida la cache dei moduli
/// </summary>
[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<string> 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

View File

@@ -17,8 +17,12 @@ builder.Services.AddDbContext<AppollinareDbContext>(options =>
builder.Services.AddScoped<EventoCostiService>(); builder.Services.AddScoped<EventoCostiService>();
builder.Services.AddScoped<DemoDataService>(); builder.Services.AddScoped<DemoDataService>();
builder.Services.AddScoped<ReportGeneratorService>(); builder.Services.AddScoped<ReportGeneratorService>();
builder.Services.AddScoped<ModuleService>();
builder.Services.AddSingleton<DataNotificationService>(); builder.Services.AddSingleton<DataNotificationService>();
// Memory cache for module state
builder.Services.AddMemoryCache();
// SignalR - with increased message size for template sync (default is 32KB) // SignalR - with increased message size for template sync (default is 32KB)
builder.Services.AddSignalR(options => builder.Services.AddSignalR(options =>
{ {
@@ -61,6 +65,11 @@ if (app.Environment.IsDevelopment())
var db = scope.ServiceProvider.GetRequiredService<AppollinareDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppollinareDbContext>();
db.Database.EnsureCreated(); db.Database.EnsureCreated();
DbSeeder.Seed(db); DbSeeder.Seed(db);
// Seed default modules
var moduleService = scope.ServiceProvider.GetRequiredService<ModuleService>();
await moduleService.SeedDefaultModulesAsync();
app.MapOpenApi(); app.MapOpenApi();
} }

View File

@@ -0,0 +1,493 @@
using Apollinare.Domain.Entities;
using Apollinare.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace Apollinare.API.Services;
/// <summary>
/// Service per la gestione dei moduli applicativi e delle relative subscription
/// </summary>
public class ModuleService
{
private readonly AppollinareDbContext _context;
private readonly IMemoryCache _cache;
private readonly ILogger<ModuleService> _logger;
private const string MODULES_CACHE_KEY = "modules_all";
private const string ACTIVE_MODULES_CACHE_KEY = "modules_active";
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public ModuleService(
AppollinareDbContext context,
IMemoryCache cache,
ILogger<ModuleService> logger)
{
_context = context;
_cache = cache;
_logger = logger;
}
/// <summary>
/// Ottiene tutti i moduli con lo stato della subscription
/// </summary>
public async Task<List<AppModule>> GetAllModulesAsync()
{
return await _cache.GetOrCreateAsync(MODULES_CACHE_KEY, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return await _context.AppModules
.Include(m => m.Subscription)
.Where(m => m.IsAvailable)
.OrderBy(m => m.SortOrder)
.ThenBy(m => m.Name)
.ToListAsync();
}) ?? new List<AppModule>();
}
/// <summary>
/// Ottiene solo i moduli attivi (per la costruzione del menu)
/// </summary>
public async Task<List<AppModule>> GetActiveModulesAsync()
{
return await _cache.GetOrCreateAsync(ACTIVE_MODULES_CACHE_KEY, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
var modules = await _context.AppModules
.Include(m => m.Subscription)
.Where(m => m.IsAvailable)
.OrderBy(m => m.SortOrder)
.ThenBy(m => m.Name)
.ToListAsync();
return modules.Where(m => m.IsCore || (m.Subscription?.IsValid() ?? false)).ToList();
}) ?? new List<AppModule>();
}
/// <summary>
/// Ottiene un modulo specifico per codice
/// </summary>
public async Task<AppModule?> GetModuleByCodeAsync(string code)
{
return await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
}
/// <summary>
/// Verifica se un modulo è attualmente abilitato
/// </summary>
public async Task<bool> IsModuleEnabledAsync(string code)
{
var module = await GetModuleByCodeAsync(code);
if (module == null)
return false;
// I moduli core sono sempre abilitati
if (module.IsCore)
return true;
return module.Subscription?.IsValid() ?? false;
}
/// <summary>
/// Verifica se un modulo ha una subscription valida (non scaduta)
/// </summary>
public async Task<bool> HasValidSubscriptionAsync(string code)
{
var module = await GetModuleByCodeAsync(code);
return module?.Subscription?.IsValid() ?? false;
}
/// <summary>
/// Attiva un modulo creando o aggiornando la subscription
/// </summary>
public async Task<ModuleSubscription> EnableModuleAsync(
string code,
SubscriptionType subscriptionType,
DateTime? startDate = null,
DateTime? endDate = null,
bool autoRenew = false,
decimal? paidPrice = null,
string? notes = null)
{
var module = await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
if (module == null)
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
if (module.IsCore)
throw new InvalidOperationException("I moduli core non possono essere attivati/disattivati manualmente");
// Verifica dipendenze
var missingDeps = await CheckDependenciesAsync(module);
if (missingDeps.Any())
throw new InvalidOperationException(
$"Il modulo richiede i seguenti moduli attivi: {string.Join(", ", missingDeps)}");
var now = DateTime.UtcNow;
var effectiveStartDate = startDate ?? now;
// Calcola data fine se non specificata
DateTime? effectiveEndDate = endDate;
if (!effectiveEndDate.HasValue && subscriptionType != SubscriptionType.None)
{
effectiveEndDate = subscriptionType switch
{
SubscriptionType.Monthly => effectiveStartDate.AddMonths(1),
SubscriptionType.Annual => effectiveStartDate.AddYears(1),
_ => null
};
}
if (module.Subscription == null)
{
// Crea nuova subscription
module.Subscription = new ModuleSubscription
{
ModuleId = module.Id,
IsEnabled = true,
SubscriptionType = subscriptionType,
StartDate = effectiveStartDate,
EndDate = effectiveEndDate,
AutoRenew = autoRenew,
PaidPrice = paidPrice ?? module.BasePrice,
Notes = notes,
CreatedAt = now,
UpdatedAt = now
};
_context.ModuleSubscriptions.Add(module.Subscription);
}
else
{
// Aggiorna subscription esistente
module.Subscription.IsEnabled = true;
module.Subscription.SubscriptionType = subscriptionType;
module.Subscription.StartDate = effectiveStartDate;
module.Subscription.EndDate = effectiveEndDate;
module.Subscription.AutoRenew = autoRenew;
module.Subscription.PaidPrice = paidPrice ?? module.Subscription.PaidPrice ?? module.BasePrice;
if (notes != null) module.Subscription.Notes = notes;
module.Subscription.UpdatedAt = now;
}
await _context.SaveChangesAsync();
InvalidateCache();
_logger.LogInformation(
"Modulo {ModuleCode} attivato con subscription {Type} fino a {EndDate}",
code, subscriptionType, effectiveEndDate);
return module.Subscription;
}
/// <summary>
/// Disattiva un modulo (mantiene i dati ma rimuove l'accesso)
/// </summary>
public async Task DisableModuleAsync(string code)
{
var module = await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
if (module == null)
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
if (module.IsCore)
throw new InvalidOperationException("I moduli core non possono essere disattivati");
// Verifica se altri moduli dipendono da questo
var dependentModules = await GetDependentModulesAsync(code);
var activeDependents = dependentModules.Where(m => m.Subscription?.IsValid() ?? false).ToList();
if (activeDependents.Any())
throw new InvalidOperationException(
$"I seguenti moduli attivi dipendono da questo modulo: {string.Join(", ", activeDependents.Select(m => m.Name))}");
if (module.Subscription != null)
{
module.Subscription.IsEnabled = false;
module.Subscription.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
InvalidateCache();
_logger.LogInformation("Modulo {ModuleCode} disattivato", code);
}
/// <summary>
/// Aggiorna i dettagli della subscription
/// </summary>
public async Task<ModuleSubscription> UpdateSubscriptionAsync(
string code,
SubscriptionType? subscriptionType = null,
DateTime? endDate = null,
bool? autoRenew = null,
string? notes = null)
{
var module = await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
if (module == null)
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
if (module.Subscription == null)
throw new InvalidOperationException($"Il modulo '{code}' non ha una subscription attiva");
if (subscriptionType.HasValue)
module.Subscription.SubscriptionType = subscriptionType.Value;
if (endDate.HasValue)
module.Subscription.EndDate = endDate.Value;
if (autoRenew.HasValue)
module.Subscription.AutoRenew = autoRenew.Value;
if (notes != null)
module.Subscription.Notes = notes;
module.Subscription.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
InvalidateCache();
return module.Subscription;
}
/// <summary>
/// Rinnova una subscription esistente
/// </summary>
public async Task<ModuleSubscription> RenewSubscriptionAsync(string code, decimal? paidPrice = null)
{
var module = await _context.AppModules
.Include(m => m.Subscription)
.FirstOrDefaultAsync(m => m.Code == code);
if (module == null)
throw new ArgumentException($"Modulo con codice '{code}' non trovato");
if (module.Subscription == null)
throw new InvalidOperationException($"Il modulo '{code}' non ha una subscription da rinnovare");
var now = DateTime.UtcNow;
var currentEnd = module.Subscription.EndDate ?? now;
var newStart = currentEnd > now ? currentEnd : now;
var newEnd = module.Subscription.SubscriptionType switch
{
SubscriptionType.Monthly => newStart.AddMonths(1),
SubscriptionType.Annual => newStart.AddYears(1),
_ => newStart.AddYears(1) // Default to annual
};
module.Subscription.StartDate = newStart;
module.Subscription.EndDate = newEnd;
module.Subscription.LastRenewalDate = now;
module.Subscription.IsEnabled = true;
module.Subscription.PaidPrice = paidPrice ?? module.Subscription.PaidPrice;
module.Subscription.UpdatedAt = now;
await _context.SaveChangesAsync();
InvalidateCache();
_logger.LogInformation(
"Modulo {ModuleCode} rinnovato fino a {EndDate}",
code, newEnd);
return module.Subscription;
}
/// <summary>
/// Ottiene tutte le subscription
/// </summary>
public async Task<List<ModuleSubscription>> GetAllSubscriptionsAsync()
{
return await _context.ModuleSubscriptions
.Include(s => s.Module)
.OrderBy(s => s.Module.SortOrder)
.ToListAsync();
}
/// <summary>
/// Verifica e disattiva i moduli con subscription scaduta (per job schedulato)
/// </summary>
public async Task<int> CheckExpiredSubscriptionsAsync()
{
var expiredSubscriptions = await _context.ModuleSubscriptions
.Include(s => s.Module)
.Where(s => s.IsEnabled &&
s.EndDate.HasValue &&
s.EndDate.Value < DateTime.UtcNow &&
!s.AutoRenew)
.ToListAsync();
foreach (var subscription in expiredSubscriptions)
{
subscription.IsEnabled = false;
subscription.UpdatedAt = DateTime.UtcNow;
_logger.LogWarning(
"Modulo {ModuleCode} disattivato per scadenza subscription",
subscription.Module.Code);
}
if (expiredSubscriptions.Any())
{
await _context.SaveChangesAsync();
InvalidateCache();
}
return expiredSubscriptions.Count;
}
/// <summary>
/// Ottiene i moduli in scadenza entro N giorni
/// </summary>
public async Task<List<AppModule>> GetExpiringModulesAsync(int daysThreshold = 30)
{
var thresholdDate = DateTime.UtcNow.AddDays(daysThreshold);
return await _context.AppModules
.Include(m => m.Subscription)
.Where(m => m.Subscription != null &&
m.Subscription.IsEnabled &&
m.Subscription.EndDate.HasValue &&
m.Subscription.EndDate.Value <= thresholdDate &&
m.Subscription.EndDate.Value > DateTime.UtcNow)
.OrderBy(m => m.Subscription!.EndDate)
.ToListAsync();
}
/// <summary>
/// Verifica le dipendenze mancanti per un modulo
/// </summary>
private async Task<List<string>> CheckDependenciesAsync(AppModule module)
{
var dependencies = module.GetDependencies().ToList();
if (!dependencies.Any())
return new List<string>();
var missingDeps = new List<string>();
foreach (var depCode in dependencies)
{
if (!await IsModuleEnabledAsync(depCode))
{
var depModule = await GetModuleByCodeAsync(depCode);
missingDeps.Add(depModule?.Name ?? depCode);
}
}
return missingDeps;
}
/// <summary>
/// Ottiene i moduli che dipendono da un determinato modulo
/// </summary>
private async Task<List<AppModule>> GetDependentModulesAsync(string code)
{
var allModules = await GetAllModulesAsync();
return allModules
.Where(m => m.GetDependencies().Contains(code))
.ToList();
}
/// <summary>
/// Invalida la cache dei moduli
/// </summary>
public void InvalidateCache()
{
_cache.Remove(MODULES_CACHE_KEY);
_cache.Remove(ACTIVE_MODULES_CACHE_KEY);
_logger.LogDebug("Cache moduli invalidata");
}
/// <summary>
/// Inizializza i moduli di default se non esistono
/// </summary>
public async Task SeedDefaultModulesAsync()
{
if (await _context.AppModules.AnyAsync())
return;
var defaultModules = new List<AppModule>
{
new AppModule
{
Code = "warehouse",
Name = "Magazzino",
Description = "Gestione inventario, movimenti di magazzino, giacenze e valorizzazione scorte",
Icon = "Warehouse",
BasePrice = 1200m,
MonthlyMultiplier = 1.2m,
SortOrder = 10,
IsCore = false,
RoutePath = "/warehouse",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "purchases",
Name = "Acquisti",
Description = "Gestione ordini fornitori, DDT in entrata, fatture passive e analisi acquisti",
Icon = "ShoppingCart",
BasePrice = 1500m,
MonthlyMultiplier = 1.2m,
SortOrder = 20,
IsCore = false,
Dependencies = "warehouse",
RoutePath = "/purchases",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "sales",
Name = "Vendite",
Description = "Gestione ordini clienti, DDT in uscita, fatture attive e analisi vendite",
Icon = "PointOfSale",
BasePrice = 1500m,
MonthlyMultiplier = 1.2m,
SortOrder = 30,
IsCore = false,
Dependencies = "warehouse",
RoutePath = "/sales",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "production",
Name = "Produzione",
Description = "Cicli produttivi, distinte base, pianificazione MRP e controllo avanzamento",
Icon = "Precision Manufacturing",
BasePrice = 2500m,
MonthlyMultiplier = 1.2m,
SortOrder = 40,
IsCore = false,
Dependencies = "warehouse",
RoutePath = "/production",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
},
new AppModule
{
Code = "quality",
Name = "Qualità",
Description = "Controlli qualità, gestione non conformità, certificazioni e audit",
Icon = "VerifiedUser",
BasePrice = 1800m,
MonthlyMultiplier = 1.2m,
SortOrder = 50,
IsCore = false,
RoutePath = "/quality",
IsAvailable = true,
CreatedAt = DateTime.UtcNow
}
};
_context.AppModules.AddRange(defaultModules);
await _context.SaveChangesAsync();
_logger.LogInformation("Seed {Count} moduli di default completato", defaultModules.Count);
}
}

View File

@@ -0,0 +1,85 @@
namespace Apollinare.Domain.Entities;
/// <summary>
/// Rappresenta un modulo dell'applicazione (es. Magazzino, Acquisti, Vendite).
/// I moduli possono essere attivati/disattivati per gestire licenze e funzionalità.
/// </summary>
public class AppModule : BaseEntity
{
/// <summary>
/// Codice univoco del modulo (es. "warehouse", "purchases", "sales")
/// </summary>
public required string Code { get; set; }
/// <summary>
/// Nome visualizzato del modulo (es. "Magazzino", "Acquisti")
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Descrizione estesa delle funzionalità del modulo
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Nome dell'icona Material UI (es. "Warehouse", "ShoppingCart")
/// </summary>
public string? Icon { get; set; }
/// <summary>
/// Prezzo base annuale del modulo in EUR
/// </summary>
public decimal BasePrice { get; set; }
/// <summary>
/// Moltiplicatore per abbonamento mensile (es. 1.2 = 20% in più rispetto all'annuale/12)
/// </summary>
public decimal MonthlyMultiplier { get; set; } = 1.2m;
/// <summary>
/// Ordine di visualizzazione nel menu (più basso = prima)
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Se true, il modulo fa parte del core e non può essere disattivato
/// </summary>
public bool IsCore { get; set; }
/// <summary>
/// Lista di codici modulo prerequisiti separati da virgola (es. "warehouse,purchases")
/// </summary>
public string? Dependencies { get; set; }
/// <summary>
/// Path base per le route frontend del modulo (es. "/warehouse")
/// </summary>
public string? RoutePath { get; set; }
/// <summary>
/// Se false, il modulo è nascosto e non disponibile per l'acquisto
/// </summary>
public bool IsAvailable { get; set; } = true;
// Navigation property
public ModuleSubscription? Subscription { get; set; }
/// <summary>
/// Restituisce la lista dei codici modulo prerequisiti
/// </summary>
public IEnumerable<string> GetDependencies()
{
if (string.IsNullOrWhiteSpace(Dependencies))
return Enumerable.Empty<string>();
return Dependencies.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <summary>
/// Calcola il prezzo mensile basato su BasePrice e MonthlyMultiplier
/// </summary>
public decimal GetMonthlyPrice()
{
return Math.Round((BasePrice / 12) * MonthlyMultiplier, 2);
}
}

View File

@@ -0,0 +1,108 @@
namespace Apollinare.Domain.Entities;
/// <summary>
/// Tipo di abbonamento per un modulo
/// </summary>
public enum SubscriptionType
{
/// <summary>Nessun abbonamento attivo</summary>
None = 0,
/// <summary>Abbonamento mensile</summary>
Monthly = 1,
/// <summary>Abbonamento annuale</summary>
Annual = 2
}
/// <summary>
/// Rappresenta lo stato di abbonamento/attivazione di un modulo per questa istanza dell'applicazione.
/// Ogni ModuleSubscription è collegata 1:1 con un AppModule.
/// </summary>
public class ModuleSubscription : BaseEntity
{
/// <summary>
/// ID del modulo associato
/// </summary>
public int ModuleId { get; set; }
/// <summary>
/// Se true, il modulo è attualmente attivo e accessibile
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Tipo di abbonamento corrente
/// </summary>
public SubscriptionType SubscriptionType { get; set; } = SubscriptionType.None;
/// <summary>
/// Data di inizio dell'abbonamento corrente
/// </summary>
public DateTime? StartDate { get; set; }
/// <summary>
/// Data di scadenza dell'abbonamento (null = nessuna scadenza, es. licenza perpetua)
/// </summary>
public DateTime? EndDate { get; set; }
/// <summary>
/// Se true, l'abbonamento si rinnova automaticamente alla scadenza
/// </summary>
public bool AutoRenew { get; set; }
/// <summary>
/// Note aggiuntive sull'abbonamento (es. codice ordine, riferimento contratto)
/// </summary>
public string? Notes { get; set; }
/// <summary>
/// Data dell'ultimo rinnovo effettuato
/// </summary>
public DateTime? LastRenewalDate { get; set; }
/// <summary>
/// Prezzo pagato per l'abbonamento corrente (può differire da BasePrice per sconti)
/// </summary>
public decimal? PaidPrice { get; set; }
// Navigation property
public AppModule Module { get; set; } = null!;
/// <summary>
/// Verifica se l'abbonamento è attualmente valido (attivo e non scaduto)
/// </summary>
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;
}
/// <summary>
/// Calcola i giorni rimanenti alla scadenza (null se nessuna scadenza)
/// </summary>
public int? GetDaysRemaining()
{
if (!EndDate.HasValue)
return null;
var remaining = (EndDate.Value - DateTime.UtcNow).Days;
return remaining < 0 ? 0 : remaining;
}
/// <summary>
/// Verifica se l'abbonamento sta per scadere (entro i prossimi N giorni)
/// </summary>
public bool IsExpiringSoon(int daysThreshold = 30)
{
if (!EndDate.HasValue)
return false;
var daysRemaining = GetDaysRemaining();
return daysRemaining.HasValue && daysRemaining.Value <= daysThreshold && daysRemaining.Value > 0;
}
}

View File

@@ -36,6 +36,10 @@ public class AppollinareDbContext : DbContext
public DbSet<ReportImage> ReportImages => Set<ReportImage>(); public DbSet<ReportImage> ReportImages => Set<ReportImage>();
public DbSet<VirtualDataset> VirtualDatasets => Set<VirtualDataset>(); public DbSet<VirtualDataset> VirtualDatasets => Set<VirtualDataset>();
// Module system entities
public DbSet<AppModule> AppModules => Set<AppModule>();
public DbSet<ModuleSubscription> ModuleSubscriptions => Set<ModuleSubscription>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -225,5 +229,32 @@ public class AppollinareDbContext : DbContext
entity.HasIndex(e => e.Nome).IsUnique(); entity.HasIndex(e => e.Nome).IsUnique();
entity.HasIndex(e => e.Categoria); entity.HasIndex(e => e.Categoria);
}); });
// AppModule
modelBuilder.Entity<AppModule>(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<ModuleSubscription>(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<ModuleSubscription>(e => e.ModuleId)
.OnDelete(DeleteBehavior.Cascade);
});
} }
} }