-
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
|
## Quick Start - Session Recovery
|
||||||
|
|
||||||
**Ultima sessione:** 29 Novembre 2025
|
**Ultima sessione:** 29 Novembre 2025 (sera)
|
||||||
|
|
||||||
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
|
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
|
||||||
|
|
||||||
**Lavoro completato nell'ultima sessione:**
|
**Lavoro completato nell'ultima sessione:**
|
||||||
|
|
||||||
|
- **NUOVA FEATURE: Sistema Moduli Applicativi** - COMPLETATO (continuazione)
|
||||||
|
- **Obiettivo:** Sistema di modularizzazione per gestire licenze, abbonamenti e funzionalità dinamiche
|
||||||
|
- **Backend implementato:**
|
||||||
|
- `AppModule.cs` - Entity per definizione moduli (code, name, description, icon, basePrice, dependencies, etc.)
|
||||||
|
- `ModuleSubscription.cs` - Entity per stato abbonamento (isEnabled, subscriptionType, startDate, endDate, autoRenew)
|
||||||
|
- `ModuleService.cs` - Logica business (enable/disable, check dipendenze, gestione scadenze, cache)
|
||||||
|
- `ModulesController.cs` - API REST complete con DTOs
|
||||||
|
- Tabelle SQLite create manualmente (AppModules, ModuleSubscriptions)
|
||||||
|
- Seed automatico 5 moduli: warehouse, purchases, sales, production, quality
|
||||||
|
- **Frontend implementato:**
|
||||||
|
- `module.ts` - Types TypeScript (ModuleDto, SubscriptionDto, enums, helpers)
|
||||||
|
- `moduleService.ts` - API calls
|
||||||
|
- `ModuleContext.tsx` - React Context con hooks (useModules, useModuleEnabled, useActiveModules)
|
||||||
|
- `ModuleGuard.tsx` - Componente per proteggere route
|
||||||
|
- `ModulesAdminPage.tsx` - Pagina amministrazione moduli con cards, toggle, dettagli subscription
|
||||||
|
- `ModulePurchasePage.tsx` - Pagina acquisto/attivazione modulo con selezione piano
|
||||||
|
- **Integrazione:**
|
||||||
|
- `App.tsx` - ModuleProvider wrappa l'app, route /modules e /modules/purchase/:code
|
||||||
|
- `Layout.tsx` - Voce menu "Moduli" aggiunta
|
||||||
|
- **API Endpoints:**
|
||||||
|
- `GET /api/modules` - Lista tutti i moduli
|
||||||
|
- `GET /api/modules/active` - Solo moduli attivi
|
||||||
|
- `GET /api/modules/{code}` - Dettaglio modulo
|
||||||
|
- `GET /api/modules/{code}/enabled` - Verifica stato
|
||||||
|
- `PUT /api/modules/{code}/enable` - Attiva modulo
|
||||||
|
- `PUT /api/modules/{code}/disable` - Disattiva modulo
|
||||||
|
- `GET /api/modules/subscriptions` - Lista subscription
|
||||||
|
- `PUT /api/modules/{code}/subscription` - Aggiorna subscription
|
||||||
|
- `POST /api/modules/{code}/subscription/renew` - Rinnova
|
||||||
|
- `GET /api/modules/expiring` - Moduli in scadenza
|
||||||
|
- **Funzionalità:**
|
||||||
|
- Gestione dipendenze tra moduli (es. purchases richiede warehouse)
|
||||||
|
- Blocco disattivazione se altri moduli dipendono
|
||||||
|
- Abbonamenti mensili/annuali con date scadenza
|
||||||
|
- Auto-rinnovo opzionale
|
||||||
|
- Cache con invalidazione automatica
|
||||||
|
- Alert moduli in scadenza
|
||||||
|
|
||||||
|
**Lavoro completato nelle sessioni precedenti (29 Novembre 2025 mattina):**
|
||||||
|
|
||||||
- **NUOVA FEATURE: Sistema Pannelli Drag-and-Drop con Sidebar Ridimensionabili** - COMPLETATO
|
- **NUOVA FEATURE: Sistema Pannelli Drag-and-Drop con Sidebar Ridimensionabili** - COMPLETATO
|
||||||
- **Obiettivo:** I pannelli del report designer devono poter essere trascinati tra sidebar sinistra e destra, con ridimensionamento orizzontale a livello sidebar
|
- **Obiettivo:** I pannelli del report designer devono poter essere trascinati tra sidebar sinistra e destra, con ridimensionamento orizzontale a livello sidebar
|
||||||
- **Architettura implementata:**
|
- **Architettura implementata:**
|
||||||
@@ -274,15 +314,23 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
|
|||||||
- **PDF Generator:** `/src/Apollinare.API/Services/Reports/ReportGeneratorService.cs`
|
- **PDF Generator:** `/src/Apollinare.API/Services/Reports/ReportGeneratorService.cs`
|
||||||
- **Page Navigator:** `/frontend/src/components/reportEditor/PageNavigator.tsx`
|
- **Page Navigator:** `/frontend/src/components/reportEditor/PageNavigator.tsx`
|
||||||
|
|
||||||
**Prossimi task prioritari (Report System):**
|
**Prossimi task prioritari:**
|
||||||
|
|
||||||
1. [x] ~~**CRITICO: Posizionamento assoluto PDF**~~ - COMPLETATO
|
**MODULI BUSINESS (PRIORITÀ ALTA):**
|
||||||
2. [x] ~~Implementare caricamento immagini reali~~ - COMPLETATO
|
|
||||||
3. [x] ~~**FIX: Rotazione oggetti nel PDF**~~ - COMPLETATO
|
1. [ ] **Implementare modulo Magazzino (warehouse)** - Base per tutti gli altri
|
||||||
4. [x] ~~**Gestione Multi-Pagina**~~ - COMPLETATO
|
2. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino
|
||||||
5. [ ] Aggiungere rendering tabelle dinamiche per collection
|
3. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino
|
||||||
6. [ ] Gestire sezioni header/footer ripetute su ogni pagina
|
4. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino
|
||||||
7. [ ] UI per relazioni tra dataset multipli
|
5. [ ] **Implementare modulo Qualità (quality)** - Indipendente
|
||||||
|
|
||||||
|
**Report System (completamento):**
|
||||||
|
|
||||||
|
- [ ] Aggiungere rendering tabelle dinamiche per collection
|
||||||
|
- [ ] Gestire sezioni header/footer ripetute su ogni pagina
|
||||||
|
- [ ] UI per relazioni tra dataset multipli
|
||||||
|
|
||||||
|
**NOTA:** Vedere sezione "Prossimi Passi: Implementazione Moduli Business" per dettagli architetturali e principi di personalizzazione.
|
||||||
|
|
||||||
**Comandi utili (usa il Makefile!):**
|
**Comandi utili (usa il Makefile!):**
|
||||||
|
|
||||||
@@ -1345,3 +1393,465 @@ Migration già applicata per SQLite.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Menu aggiunto in `Layout.tsx` sotto "Report" con icona PrintIcon.
|
Menu aggiunto in `Layout.tsx` sotto "Report" con icona PrintIcon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sistema Moduli - Architettura e Implementazione
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Sistema di modularizzazione dell'applicazione per gestione licenze, abbonamenti e funzionalità dinamiche. Ogni modulo rappresenta una sezione business completa (es. Magazzino, Acquisti, Vendite) che può essere attivata/disattivata per cliente.
|
||||||
|
|
||||||
|
### Requisiti Funzionali
|
||||||
|
|
||||||
|
**Moduli previsti:**
|
||||||
|
|
||||||
|
- `warehouse` - Magazzino (gestione inventario, movimenti, giacenze)
|
||||||
|
- `purchases` - Acquisti (ordini fornitori, DDT entrata, fatture passive)
|
||||||
|
- `sales` - Vendite (ordini clienti, DDT uscita, fatture attive)
|
||||||
|
- `production` - Produzione (cicli produttivi, distinte base, MRP)
|
||||||
|
- `quality` - Qualità (controlli, non conformità, certificazioni)
|
||||||
|
|
||||||
|
**Funzionalità Core (sempre attive):**
|
||||||
|
|
||||||
|
- Report e template PDF
|
||||||
|
- Gestione utenti e autenticazione (futuro)
|
||||||
|
- Dashboard e navigazione base
|
||||||
|
- Impostazioni sistema
|
||||||
|
|
||||||
|
### Comportamento UI
|
||||||
|
|
||||||
|
1. **Modulo attivo:** Menu visibile, route accessibili, funzionalità complete
|
||||||
|
2. **Modulo disattivato:**
|
||||||
|
- Menu nascosto
|
||||||
|
- Route redirect a pagina `/modules/purchase/{moduleCode}`
|
||||||
|
- Le funzioni di altri moduli che usavano dati del modulo disattivato continuano a funzionare (dati storici preservati)
|
||||||
|
|
||||||
|
### Modello Dati
|
||||||
|
|
||||||
|
```
|
||||||
|
AppModule (tabella moduli disponibili)
|
||||||
|
├── Id: int (PK)
|
||||||
|
├── Code: string (unique, es. "warehouse")
|
||||||
|
├── Name: string (es. "Magazzino")
|
||||||
|
├── Description: string
|
||||||
|
├── Icon: string (nome icona MUI)
|
||||||
|
├── BasePrice: decimal (prezzo base annuale)
|
||||||
|
├── MonthlyMultiplier: decimal (moltiplicatore per abbonamento mensile, es. 1.2)
|
||||||
|
├── SortOrder: int (ordine nel menu)
|
||||||
|
├── IsCore: bool (true = sempre attivo, non disattivabile)
|
||||||
|
├── Dependencies: string[] (codici moduli prerequisiti)
|
||||||
|
├── CreatedAt: DateTime
|
||||||
|
├── UpdatedAt: DateTime
|
||||||
|
|
||||||
|
ModuleSubscription (stato abbonamento per istanza)
|
||||||
|
├── Id: int (PK)
|
||||||
|
├── ModuleId: int (FK → AppModule)
|
||||||
|
├── IsEnabled: bool
|
||||||
|
├── SubscriptionType: enum (None, Monthly, Annual)
|
||||||
|
├── StartDate: DateTime?
|
||||||
|
├── EndDate: DateTime?
|
||||||
|
├── AutoRenew: bool
|
||||||
|
├── CreatedAt: DateTime
|
||||||
|
├── UpdatedAt: DateTime
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
# Moduli (lettura per tutti, scrittura solo admin)
|
||||||
|
GET /api/modules # Lista tutti i moduli con stato subscription
|
||||||
|
GET /api/modules/{code} # Dettaglio singolo modulo
|
||||||
|
GET /api/modules/active # Solo moduli attivi (per menu)
|
||||||
|
PUT /api/modules/{code}/enable # Attiva modulo
|
||||||
|
PUT /api/modules/{code}/disable # Disattiva modulo
|
||||||
|
|
||||||
|
# Subscriptions (admin only)
|
||||||
|
GET /api/modules/subscriptions # Lista tutte le subscription
|
||||||
|
PUT /api/modules/{code}/subscription # Aggiorna subscription (tipo, date)
|
||||||
|
POST /api/modules/{code}/subscription/renew # Rinnova abbonamento
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Architecture
|
||||||
|
|
||||||
|
**Context e State:**
|
||||||
|
|
||||||
|
- `ModuleContext.tsx` - React Context con stato moduli globale
|
||||||
|
- `useModules()` - Hook per accesso a lista moduli
|
||||||
|
- `useModuleEnabled(code)` - Hook per check singolo modulo
|
||||||
|
- `useActiveModules()` - Hook per moduli attivi (per menu)
|
||||||
|
|
||||||
|
**Componenti:**
|
||||||
|
|
||||||
|
- `ModuleGuard.tsx` - HOC/wrapper che verifica accesso a route
|
||||||
|
- `ModulePurchasePage.tsx` - Pagina acquisto/attivazione modulo
|
||||||
|
- `ModulesAdminPage.tsx` - Pannello admin gestione moduli
|
||||||
|
|
||||||
|
**Integrazione Menu (Layout.tsx):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Filtra voci menu in base a moduli attivi
|
||||||
|
const menuItems = allMenuItems.filter(
|
||||||
|
(item) => !item.moduleCode || activeModuleCodes.includes(item.moduleCode),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Routing (App.tsx):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Route protette da modulo
|
||||||
|
<Route
|
||||||
|
path="/warehouse/*"
|
||||||
|
element={
|
||||||
|
<ModuleGuard moduleCode="warehouse">
|
||||||
|
<WarehouseRoutes />
|
||||||
|
</ModuleGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Pagina acquisto modulo
|
||||||
|
<Route path="/modules/purchase/:code" element={<ModulePurchasePage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logica Backend
|
||||||
|
|
||||||
|
**ModuleService:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ModuleService
|
||||||
|
{
|
||||||
|
// Verifica se modulo è attivo (usato da altri servizi)
|
||||||
|
public async Task<bool> IsModuleEnabledAsync(string code);
|
||||||
|
|
||||||
|
// Verifica subscription valida (non scaduta)
|
||||||
|
public async Task<bool> HasValidSubscriptionAsync(string code);
|
||||||
|
|
||||||
|
// Attiva modulo (crea/aggiorna subscription)
|
||||||
|
public async Task EnableModuleAsync(string code, SubscriptionType type, DateTime? endDate);
|
||||||
|
|
||||||
|
// Disattiva modulo (preserva dati)
|
||||||
|
public async Task DisableModuleAsync(string code);
|
||||||
|
|
||||||
|
// Job schedulato: controlla scadenze e disattiva moduli scaduti
|
||||||
|
public async Task CheckExpiredSubscriptionsAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Principi di Design
|
||||||
|
|
||||||
|
1. **Riutilizzo codice:** I moduli possono importare servizi/componenti da altri moduli
|
||||||
|
2. **Dati persistenti:** Disattivare un modulo non elimina i dati, solo nasconde l'accesso
|
||||||
|
3. **Dipendenze:** Un modulo può richiedere altri moduli (es. Produzione richiede Magazzino)
|
||||||
|
4. **Core inattaccabile:** Report, utenti, dashboard non sono disattivabili
|
||||||
|
5. **Check lato backend:** La verifica modulo avviene sempre sul server, mai solo frontend
|
||||||
|
6. **Cache:** Stato moduli cachato con invalidazione su modifica
|
||||||
|
|
||||||
|
### Struttura File
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Apollinare.Domain/Entities/
|
||||||
|
├── AppModule.cs
|
||||||
|
└── ModuleSubscription.cs
|
||||||
|
|
||||||
|
src/Apollinare.API/
|
||||||
|
├── Controllers/
|
||||||
|
│ └── ModulesController.cs
|
||||||
|
├── Services/
|
||||||
|
│ └── ModuleService.cs
|
||||||
|
└── DTOs/
|
||||||
|
└── ModuleDtos.cs
|
||||||
|
|
||||||
|
frontend/src/
|
||||||
|
├── contexts/
|
||||||
|
│ └── ModuleContext.tsx
|
||||||
|
├── components/
|
||||||
|
│ └── ModuleGuard.tsx
|
||||||
|
├── pages/
|
||||||
|
│ ├── ModulesAdminPage.tsx
|
||||||
|
│ └── ModulePurchasePage.tsx
|
||||||
|
├── services/
|
||||||
|
│ └── moduleService.ts
|
||||||
|
└── types/
|
||||||
|
└── module.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checklist Implementazione
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
|
||||||
|
- [x] Entity `AppModule`
|
||||||
|
- [x] Entity `ModuleSubscription`
|
||||||
|
- [x] `ModuleService` con logica business
|
||||||
|
- [x] `ModulesController` con tutti gli endpoint
|
||||||
|
- [x] DbSet e migration EF Core (tabelle create manualmente in SQLite)
|
||||||
|
- [x] Seed dati iniziali (5 moduli)
|
||||||
|
- [ ] Job controllo scadenze (opzionale - futuro)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
|
||||||
|
- [x] Types `module.ts`
|
||||||
|
- [x] Service `moduleService.ts`
|
||||||
|
- [x] Context `ModuleContext.tsx`
|
||||||
|
- [x] Component `ModuleGuard.tsx`
|
||||||
|
- [x] Page `ModulesAdminPage.tsx`
|
||||||
|
- [x] Page `ModulePurchasePage.tsx`
|
||||||
|
- [x] Integrazione `Layout.tsx` per menu dinamico
|
||||||
|
- [x] Route protection in `App.tsx`
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
|
||||||
|
- [x] API CRUD moduli
|
||||||
|
- [x] API subscription
|
||||||
|
- [x] Redirect su modulo disattivato
|
||||||
|
- [x] Menu filtrato correttamente
|
||||||
|
- [ ] Scadenza subscription (da testare con date reali)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prossimi Passi: Implementazione Moduli Business
|
||||||
|
|
||||||
|
**PRIORITÀ ALTA - Da implementare:**
|
||||||
|
|
||||||
|
I moduli sono stati definiti a livello infrastrutturale (sistema licenze/abbonamenti). Ora bisogna implementare le funzionalità reali di ogni modulo.
|
||||||
|
|
||||||
|
### Ordine di Implementazione Consigliato
|
||||||
|
|
||||||
|
1. **Magazzino (warehouse)** - Base per tutti gli altri moduli
|
||||||
|
2. **Acquisti (purchases)** - Dipende da Magazzino
|
||||||
|
3. **Vendite (sales)** - Dipende da Magazzino
|
||||||
|
4. **Produzione (production)** - Dipende da Magazzino
|
||||||
|
5. **Qualità (quality)** - Indipendente
|
||||||
|
|
||||||
|
### Architettura Modulare - Principi di Personalizzazione
|
||||||
|
|
||||||
|
**IMPORTANTE:** Ogni modulo deve essere facilmente personalizzabile da codice per adattarsi a esigenze specifiche del cliente.
|
||||||
|
|
||||||
|
**Struttura consigliata per ogni modulo:**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Apollinare.API/
|
||||||
|
├── Modules/
|
||||||
|
│ ├── Warehouse/
|
||||||
|
│ │ ├── Controllers/
|
||||||
|
│ │ │ └── WarehouseController.cs
|
||||||
|
│ │ ├── Services/
|
||||||
|
│ │ │ ├── IWarehouseService.cs # Interfaccia per DI/mock
|
||||||
|
│ │ │ └── WarehouseService.cs
|
||||||
|
│ │ ├── DTOs/
|
||||||
|
│ │ │ └── WarehouseDtos.cs
|
||||||
|
│ │ └── Configuration/
|
||||||
|
│ │ └── WarehouseConfig.cs # Configurazione modulo
|
||||||
|
│ ├── Purchases/
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── Sales/
|
||||||
|
│ └── ...
|
||||||
|
|
||||||
|
src/Apollinare.Domain/
|
||||||
|
├── Entities/
|
||||||
|
│ ├── Warehouse/
|
||||||
|
│ │ ├── Article.cs
|
||||||
|
│ │ ├── StockMovement.cs
|
||||||
|
│ │ └── Warehouse.cs
|
||||||
|
│ ├── Purchases/
|
||||||
|
│ │ ├── PurchaseOrder.cs
|
||||||
|
│ │ └── Supplier.cs
|
||||||
|
│ └── Sales/
|
||||||
|
│ ├── SalesOrder.cs
|
||||||
|
│ └── Customer.cs
|
||||||
|
|
||||||
|
frontend/src/
|
||||||
|
├── modules/
|
||||||
|
│ ├── warehouse/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── ArticlesPage.tsx
|
||||||
|
│ │ │ ├── StockMovementsPage.tsx
|
||||||
|
│ │ │ └── InventoryPage.tsx
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ └── ArticleForm.tsx
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ └── warehouseService.ts
|
||||||
|
│ │ ├── types/
|
||||||
|
│ │ │ └── warehouse.ts
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ └── useWarehouse.ts
|
||||||
|
│ │ └── routes.tsx # Route del modulo
|
||||||
|
│ ├── purchases/
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── sales/
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern di Personalizzazione:**
|
||||||
|
|
||||||
|
1. **Interfacce per Services:** Usare sempre interfacce (`IWarehouseService`) per permettere override tramite DI
|
||||||
|
2. **Configurazione esterna:** Parametri configurabili in `appsettings.json` o database
|
||||||
|
3. **Hook points:** Esporre eventi/callback per estensioni custom
|
||||||
|
4. **Component composition:** Componenti React piccoli e componibili
|
||||||
|
5. **Feature flags:** Sotto-funzionalità attivabili/disattivabili per modulo
|
||||||
|
|
||||||
|
**Esempio configurazione modulo:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// WarehouseConfig.cs
|
||||||
|
public class WarehouseConfig
|
||||||
|
{
|
||||||
|
public bool EnableBarcodeScanning { get; set; } = true;
|
||||||
|
public bool EnableMultiWarehouse { get; set; } = false;
|
||||||
|
public bool EnableSerialTracking { get; set; } = false;
|
||||||
|
public bool EnableBatchTracking { get; set; } = false;
|
||||||
|
public int LowStockThreshold { get; set; } = 10;
|
||||||
|
public List<string> CustomFields { get; set; } = new();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Esempio hook point per estensioni:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// IWarehouseService.cs
|
||||||
|
public interface IWarehouseService
|
||||||
|
{
|
||||||
|
// Metodi standard
|
||||||
|
Task<Article> GetArticleAsync(int id);
|
||||||
|
Task<Article> CreateArticleAsync(ArticleDto dto);
|
||||||
|
|
||||||
|
// Hook points per customizzazione
|
||||||
|
event Func<Article, Task>? OnArticleCreated;
|
||||||
|
event Func<StockMovement, Task<bool>>? OnBeforeStockMovement;
|
||||||
|
event Func<StockMovement, Task>? OnAfterStockMovement;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dettaglio Moduli da Implementare
|
||||||
|
|
||||||
|
#### 1. Magazzino (warehouse)
|
||||||
|
|
||||||
|
**Funzionalità:**
|
||||||
|
|
||||||
|
- Anagrafica articoli (CRUD, categorie, immagini)
|
||||||
|
- Gestione magazzini multipli (opzionale)
|
||||||
|
- Movimenti di magazzino (carico, scarico, trasferimento)
|
||||||
|
- Giacenze e disponibilità
|
||||||
|
- Inventario e rettifiche
|
||||||
|
- Alert scorte minime
|
||||||
|
- Codici a barre / QR code (opzionale)
|
||||||
|
- Tracciabilità lotti/serial (opzionale)
|
||||||
|
|
||||||
|
**Entità principali:**
|
||||||
|
|
||||||
|
- `Article` - Articolo/prodotto
|
||||||
|
- `ArticleCategory` - Categorie articoli
|
||||||
|
- `Warehouse` - Magazzino fisico
|
||||||
|
- `StockMovement` - Movimento di magazzino
|
||||||
|
- `StockLevel` - Giacenza per articolo/magazzino
|
||||||
|
|
||||||
|
#### 2. Acquisti (purchases)
|
||||||
|
|
||||||
|
**Funzionalità:**
|
||||||
|
|
||||||
|
- Anagrafica fornitori
|
||||||
|
- Richieste di acquisto (RDA)
|
||||||
|
- Ordini a fornitore (OdA)
|
||||||
|
- DDT entrata (ricezione merce)
|
||||||
|
- Fatture passive
|
||||||
|
- Listini fornitore
|
||||||
|
- Storico prezzi
|
||||||
|
|
||||||
|
**Entità principali:**
|
||||||
|
|
||||||
|
- `Supplier` - Fornitore
|
||||||
|
- `PurchaseRequest` - Richiesta d'acquisto
|
||||||
|
- `PurchaseOrder` - Ordine a fornitore
|
||||||
|
- `PurchaseOrderLine` - Riga ordine
|
||||||
|
- `GoodsReceipt` - DDT entrata
|
||||||
|
- `SupplierInvoice` - Fattura passiva
|
||||||
|
|
||||||
|
#### 3. Vendite (sales)
|
||||||
|
|
||||||
|
**Funzionalità:**
|
||||||
|
|
||||||
|
- Anagrafica clienti
|
||||||
|
- Preventivi
|
||||||
|
- Ordini cliente
|
||||||
|
- DDT uscita
|
||||||
|
- Fatture attive
|
||||||
|
- Listini cliente
|
||||||
|
- Provvigioni agenti (opzionale)
|
||||||
|
|
||||||
|
**Entità principali:**
|
||||||
|
|
||||||
|
- `Customer` - Cliente
|
||||||
|
- `Quote` - Preventivo
|
||||||
|
- `SalesOrder` - Ordine cliente
|
||||||
|
- `SalesOrderLine` - Riga ordine
|
||||||
|
- `DeliveryNote` - DDT uscita
|
||||||
|
- `Invoice` - Fattura attiva
|
||||||
|
|
||||||
|
#### 4. Produzione (production)
|
||||||
|
|
||||||
|
**Funzionalità:**
|
||||||
|
|
||||||
|
- Distinte base (BOM)
|
||||||
|
- Cicli di lavorazione
|
||||||
|
- Ordini di produzione
|
||||||
|
- Avanzamento produzione
|
||||||
|
- Pianificazione (MRP semplificato)
|
||||||
|
- Costi di produzione
|
||||||
|
|
||||||
|
**Entità principali:**
|
||||||
|
|
||||||
|
- `BillOfMaterials` - Distinta base
|
||||||
|
- `BomComponent` - Componente distinta
|
||||||
|
- `WorkCenter` - Centro di lavoro
|
||||||
|
- `ProductionOrder` - Ordine di produzione
|
||||||
|
- `ProductionStep` - Fase produzione
|
||||||
|
|
||||||
|
#### 5. Qualità (quality)
|
||||||
|
|
||||||
|
**Funzionalità:**
|
||||||
|
|
||||||
|
- Piani di controllo
|
||||||
|
- Controlli in accettazione
|
||||||
|
- Controlli in produzione
|
||||||
|
- Non conformità
|
||||||
|
- Azioni correttive
|
||||||
|
- Certificazioni/documenti
|
||||||
|
|
||||||
|
**Entità principali:**
|
||||||
|
|
||||||
|
- `ControlPlan` - Piano di controllo
|
||||||
|
- `QualityCheck` - Controllo qualità
|
||||||
|
- `NonConformity` - Non conformità
|
||||||
|
- `CorrectiveAction` - Azione correttiva
|
||||||
|
- `Certificate` - Certificazione
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Problemi Risolti Durante Implementazione
|
||||||
|
|
||||||
|
31. **EF Core Migration Fallita per Tabella Esistente (FIX 29/11/2025):**
|
||||||
|
- **Problema:** `dotnet ef database update` falliva con "Table 'Clienti' already exists"
|
||||||
|
- **Causa:** La migration tentava di ricreare tutte le tabelle invece di aggiungere solo quelle nuove
|
||||||
|
- **Soluzione:** Create tabelle manualmente via SQLite:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE AppModules (
|
||||||
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Code TEXT NOT NULL UNIQUE,
|
||||||
|
Name TEXT NOT NULL,
|
||||||
|
...
|
||||||
|
);
|
||||||
|
CREATE TABLE ModuleSubscriptions (
|
||||||
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ModuleId INTEGER NOT NULL REFERENCES AppModules(Id),
|
||||||
|
...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- **File:** Database SQLite, rimossa migration problematica
|
||||||
|
|
||||||
|
32. **TypeScript Unused Variables Build Errors (FIX 29/11/2025):**
|
||||||
|
- **Problema:** Build frontend falliva per variabili importate ma non usate
|
||||||
|
- **Soluzione:** Rimossi import inutilizzati:
|
||||||
|
- `ModuleGuard.tsx`: Rimosso `CircularProgress`, `showLoader`
|
||||||
|
- `ModuleContext.tsx`: Rimosso `useState`, `useEffect`
|
||||||
|
- `ModulePurchasePage.tsx`: Rimosso `moduleService` import
|
||||||
|
- `ModulesAdminPage.tsx`: Rimosso `PowerIcon`, `CheckIcon`, `CancelIcon`
|
||||||
|
- **File:** Vari componenti frontend
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ import RisorsePage from "./pages/RisorsePage";
|
|||||||
import CalendarioPage from "./pages/CalendarioPage";
|
import CalendarioPage from "./pages/CalendarioPage";
|
||||||
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
|
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
|
||||||
import ReportEditorPage from "./pages/ReportEditorPage";
|
import ReportEditorPage from "./pages/ReportEditorPage";
|
||||||
|
import ModulesAdminPage from "./pages/ModulesAdminPage";
|
||||||
|
import ModulePurchasePage from "./pages/ModulePurchasePage";
|
||||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||||
|
import { ModuleProvider } from "./contexts/ModuleContext";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -60,34 +63,42 @@ function App() {
|
|||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<CollaborationProvider>
|
<ModuleProvider>
|
||||||
<RealTimeProvider>
|
<CollaborationProvider>
|
||||||
<Routes>
|
<RealTimeProvider>
|
||||||
<Route path="/" element={<Layout />}>
|
<Routes>
|
||||||
<Route index element={<Dashboard />} />
|
<Route path="/" element={<Layout />}>
|
||||||
<Route path="calendario" element={<CalendarioPage />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="eventi" element={<EventiPage />} />
|
<Route path="calendario" element={<CalendarioPage />} />
|
||||||
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
<Route path="eventi" element={<EventiPage />} />
|
||||||
<Route path="clienti" element={<ClientiPage />} />
|
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
||||||
<Route path="location" element={<LocationPage />} />
|
<Route path="clienti" element={<ClientiPage />} />
|
||||||
<Route path="articoli" element={<ArticoliPage />} />
|
<Route path="location" element={<LocationPage />} />
|
||||||
<Route path="risorse" element={<RisorsePage />} />
|
<Route path="articoli" element={<ArticoliPage />} />
|
||||||
<Route
|
<Route path="risorse" element={<RisorsePage />} />
|
||||||
path="report-templates"
|
<Route
|
||||||
element={<ReportTemplatesPage />}
|
path="report-templates"
|
||||||
/>
|
element={<ReportTemplatesPage />}
|
||||||
<Route
|
/>
|
||||||
path="report-editor"
|
<Route
|
||||||
element={<ReportEditorPage />}
|
path="report-editor"
|
||||||
/>
|
element={<ReportEditorPage />}
|
||||||
<Route
|
/>
|
||||||
path="report-editor/:id"
|
<Route
|
||||||
element={<ReportEditorPage />}
|
path="report-editor/:id"
|
||||||
/>
|
element={<ReportEditorPage />}
|
||||||
</Route>
|
/>
|
||||||
</Routes>
|
{/* Moduli */}
|
||||||
</RealTimeProvider>
|
<Route path="modules" element={<ModulesAdminPage />} />
|
||||||
</CollaborationProvider>
|
<Route
|
||||||
|
path="modules/purchase/:code"
|
||||||
|
element={<ModulePurchasePage />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</RealTimeProvider>
|
||||||
|
</CollaborationProvider>
|
||||||
|
</ModuleProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
CalendarMonth as CalendarIcon,
|
CalendarMonth as CalendarIcon,
|
||||||
Print as PrintIcon,
|
Print as PrintIcon,
|
||||||
Close as CloseIcon,
|
Close as CloseIcon,
|
||||||
|
Extension as ModulesIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ const menuItems = [
|
|||||||
{ text: "Articoli", icon: <InventoryIcon />, path: "/articoli" },
|
{ text: "Articoli", icon: <InventoryIcon />, path: "/articoli" },
|
||||||
{ text: "Risorse", icon: <PersonIcon />, path: "/risorse" },
|
{ text: "Risorse", icon: <PersonIcon />, path: "/risorse" },
|
||||||
{ text: "Report", icon: <PrintIcon />, path: "/report-templates" },
|
{ text: "Report", icon: <PrintIcon />, path: "/report-templates" },
|
||||||
|
{ text: "Moduli", icon: <ModulesIcon />, path: "/modules" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
|||||||
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
<PackageReference Include="QuestPDF" Version="2024.12.2" />
|
<PackageReference Include="QuestPDF" Version="2024.12.2" />
|
||||||
<PackageReference Include="SkiaSharp" Version="3.116.1" />
|
<PackageReference Include="SkiaSharp" Version="3.116.1" />
|
||||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||||
|
|||||||
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<EventoCostiService>();
|
||||||
builder.Services.AddScoped<DemoDataService>();
|
builder.Services.AddScoped<DemoDataService>();
|
||||||
builder.Services.AddScoped<ReportGeneratorService>();
|
builder.Services.AddScoped<ReportGeneratorService>();
|
||||||
|
builder.Services.AddScoped<ModuleService>();
|
||||||
builder.Services.AddSingleton<DataNotificationService>();
|
builder.Services.AddSingleton<DataNotificationService>();
|
||||||
|
|
||||||
|
// Memory cache for module state
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
|
||||||
// SignalR - with increased message size for template sync (default is 32KB)
|
// SignalR - with increased message size for template sync (default is 32KB)
|
||||||
builder.Services.AddSignalR(options =>
|
builder.Services.AddSignalR(options =>
|
||||||
{
|
{
|
||||||
@@ -61,6 +65,11 @@ if (app.Environment.IsDevelopment())
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<AppollinareDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppollinareDbContext>();
|
||||||
db.Database.EnsureCreated();
|
db.Database.EnsureCreated();
|
||||||
DbSeeder.Seed(db);
|
DbSeeder.Seed(db);
|
||||||
|
|
||||||
|
// Seed default modules
|
||||||
|
var moduleService = scope.ServiceProvider.GetRequiredService<ModuleService>();
|
||||||
|
await moduleService.SeedDefaultModulesAsync();
|
||||||
|
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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<ReportImage> ReportImages => Set<ReportImage>();
|
||||||
public DbSet<VirtualDataset> VirtualDatasets => Set<VirtualDataset>();
|
public DbSet<VirtualDataset> VirtualDatasets => Set<VirtualDataset>();
|
||||||
|
|
||||||
|
// Module system entities
|
||||||
|
public DbSet<AppModule> AppModules => Set<AppModule>();
|
||||||
|
public DbSet<ModuleSubscription> ModuleSubscriptions => Set<ModuleSubscription>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
@@ -225,5 +229,32 @@ public class AppollinareDbContext : DbContext
|
|||||||
entity.HasIndex(e => e.Nome).IsUnique();
|
entity.HasIndex(e => e.Nome).IsUnique();
|
||||||
entity.HasIndex(e => e.Categoria);
|
entity.HasIndex(e => e.Categoria);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AppModule
|
||||||
|
modelBuilder.Entity<AppModule>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(e => e.Code).IsUnique();
|
||||||
|
entity.HasIndex(e => e.SortOrder);
|
||||||
|
|
||||||
|
entity.Property(e => e.BasePrice)
|
||||||
|
.HasPrecision(18, 2);
|
||||||
|
|
||||||
|
entity.Property(e => e.MonthlyMultiplier)
|
||||||
|
.HasPrecision(5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ModuleSubscription
|
||||||
|
modelBuilder.Entity<ModuleSubscription>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(e => e.ModuleId).IsUnique();
|
||||||
|
|
||||||
|
entity.Property(e => e.PaidPrice)
|
||||||
|
.HasPrecision(18, 2);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Module)
|
||||||
|
.WithOne(m => m.Subscription)
|
||||||
|
.HasForeignKey<ModuleSubscription>(e => e.ModuleId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user