-
This commit is contained in:
528
CLAUDE.md
528
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
|
||||
<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
|
||||
|
||||
@@ -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() {
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<CollaborationProvider>
|
||||
<RealTimeProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="calendario" element={<CalendarioPage />} />
|
||||
<Route path="eventi" element={<EventiPage />} />
|
||||
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
||||
<Route path="clienti" element={<ClientiPage />} />
|
||||
<Route path="location" element={<LocationPage />} />
|
||||
<Route path="articoli" element={<ArticoliPage />} />
|
||||
<Route path="risorse" element={<RisorsePage />} />
|
||||
<Route
|
||||
path="report-templates"
|
||||
element={<ReportTemplatesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor/:id"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</RealTimeProvider>
|
||||
</CollaborationProvider>
|
||||
<ModuleProvider>
|
||||
<CollaborationProvider>
|
||||
<RealTimeProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="calendario" element={<CalendarioPage />} />
|
||||
<Route path="eventi" element={<EventiPage />} />
|
||||
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
||||
<Route path="clienti" element={<ClientiPage />} />
|
||||
<Route path="location" element={<LocationPage />} />
|
||||
<Route path="articoli" element={<ArticoliPage />} />
|
||||
<Route path="risorse" element={<RisorsePage />} />
|
||||
<Route
|
||||
path="report-templates"
|
||||
element={<ReportTemplatesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor/:id"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
{/* Moduli */}
|
||||
<Route path="modules" element={<ModulesAdminPage />} />
|
||||
<Route
|
||||
path="modules/purchase/:code"
|
||||
element={<ModulePurchasePage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</RealTimeProvider>
|
||||
</CollaborationProvider>
|
||||
</ModuleProvider>
|
||||
</BrowserRouter>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -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: <InventoryIcon />, path: "/articoli" },
|
||||
{ text: "Risorse", icon: <PersonIcon />, path: "/risorse" },
|
||||
{ text: "Report", icon: <PrintIcon />, path: "/report-templates" },
|
||||
{ text: "Moduli", icon: <ModulesIcon />, path: "/modules" },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
|
||||
75
frontend/src/components/ModuleGuard.tsx
Normal file
75
frontend/src/components/ModuleGuard.tsx
Normal 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;
|
||||
182
frontend/src/contexts/ModuleContext.tsx
Normal file
182
frontend/src/contexts/ModuleContext.tsx
Normal 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);
|
||||
}
|
||||
386
frontend/src/pages/ModulePurchasePage.tsx
Normal file
386
frontend/src/pages/ModulePurchasePage.tsx
Normal 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"];
|
||||
}
|
||||
515
frontend/src/pages/ModulesAdminPage.tsx
Normal file
515
frontend/src/pages/ModulesAdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
frontend/src/services/moduleService.ts
Normal file
128
frontend/src/services/moduleService.ts
Normal 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;
|
||||
174
frontend/src/types/module.ts
Normal file
174
frontend/src/types/module.ts
Normal 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",
|
||||
};
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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="SkiaSharp" Version="3.116.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||
|
||||
352
src/Apollinare.API/Controllers/ModulesController.cs
Normal file
352
src/Apollinare.API/Controllers/ModulesController.cs
Normal 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
|
||||
@@ -17,8 +17,12 @@ builder.Services.AddDbContext<AppollinareDbContext>(options =>
|
||||
builder.Services.AddScoped<EventoCostiService>();
|
||||
builder.Services.AddScoped<DemoDataService>();
|
||||
builder.Services.AddScoped<ReportGeneratorService>();
|
||||
builder.Services.AddScoped<ModuleService>();
|
||||
builder.Services.AddSingleton<DataNotificationService>();
|
||||
|
||||
// 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<AppollinareDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
DbSeeder.Seed(db);
|
||||
|
||||
// Seed default modules
|
||||
var moduleService = scope.ServiceProvider.GetRequiredService<ModuleService>();
|
||||
await moduleService.SeedDefaultModulesAsync();
|
||||
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
|
||||
493
src/Apollinare.API/Services/ModuleService.cs
Normal file
493
src/Apollinare.API/Services/ModuleService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
85
src/Apollinare.Domain/Entities/AppModule.cs
Normal file
85
src/Apollinare.Domain/Entities/AppModule.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
108
src/Apollinare.Domain/Entities/ModuleSubscription.cs
Normal file
108
src/Apollinare.Domain/Entities/ModuleSubscription.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@ public class AppollinareDbContext : DbContext
|
||||
public DbSet<ReportImage> ReportImages => Set<ReportImage>();
|
||||
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)
|
||||
{
|
||||
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<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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user