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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
import { Navigate, useLocation } from "react-router-dom";
import { useModuleEnabled, useModule } from "../contexts/ModuleContext";
import { Box, Typography } from "@mui/material";
interface ModuleGuardProps {
/** Codice del modulo richiesto */
moduleCode: string;
/** Contenuto da renderizzare se il modulo è abilitato */
children: React.ReactNode;
}
/**
* Componente guard che protegge le route basandosi sullo stato del modulo.
* Se il modulo non è abilitato, reindirizza alla pagina di acquisto.
*/
export function ModuleGuard({ moduleCode, children }: ModuleGuardProps) {
const location = useLocation();
const isEnabled = useModuleEnabled(moduleCode);
const module = useModule(moduleCode);
// Se il modulo non esiste (codice errato), mostra errore
if (module === undefined) {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "50vh",
gap: 2,
}}
>
<Typography variant="h5" color="error">
Modulo non trovato
</Typography>
<Typography color="text.secondary">
Il modulo "{moduleCode}" non esiste.
</Typography>
</Box>
);
}
// Se il modulo è abilitato, mostra il contenuto
if (isEnabled) {
return <>{children}</>;
}
// Modulo non abilitato: redirect alla pagina di acquisto
return (
<Navigate
to={`/modules/purchase/${moduleCode}`}
state={{ from: location.pathname }}
replace
/>
);
}
/**
* HOC per proteggere un componente con ModuleGuard
*/
export function withModuleGuard<P extends object>(
WrappedComponent: React.ComponentType<P>,
moduleCode: string,
) {
return function ModuleGuardedComponent(props: P) {
return (
<ModuleGuard moduleCode={moduleCode}>
<WrappedComponent {...props} />
</ModuleGuard>
);
};
}
export default ModuleGuard;

View File

@@ -0,0 +1,182 @@
import { createContext, useContext, useCallback, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { moduleService } from "../services/moduleService";
import type {
ModuleDto,
EnableModuleRequest,
SubscriptionDto,
} from "../types/module";
// ==================== TYPES ====================
export interface ModuleContextValue {
/** Lista di tutti i moduli disponibili */
modules: ModuleDto[];
/** Lista dei moduli attualmente attivi */
activeModules: ModuleDto[];
/** Codici dei moduli attivi (per filtro veloce) */
activeModuleCodes: string[];
/** Stato di caricamento */
isLoading: boolean;
/** Errore di caricamento */
error: Error | null;
/** Verifica se un modulo è abilitato */
isModuleEnabled: (code: string) => boolean;
/** Ottiene un modulo per codice */
getModule: (code: string) => ModuleDto | undefined;
/** Attiva un modulo */
enableModule: (
code: string,
request: EnableModuleRequest,
) => Promise<SubscriptionDto>;
/** Disattiva un modulo */
disableModule: (code: string) => Promise<void>;
/** Ricarica i dati dei moduli */
refreshModules: () => Promise<void>;
}
const ModuleContext = createContext<ModuleContextValue | null>(null);
// ==================== PROVIDER ====================
interface ModuleProviderProps {
children: ReactNode;
}
export function ModuleProvider({ children }: ModuleProviderProps) {
const queryClient = useQueryClient();
// Query per tutti i moduli
const {
data: modules = [],
isLoading: isLoadingModules,
error: modulesError,
} = useQuery({
queryKey: ["modules"],
queryFn: moduleService.getAll,
staleTime: 5 * 60 * 1000, // 5 minuti
refetchOnWindowFocus: false,
});
// Query per moduli attivi
const {
data: activeModules = [],
isLoading: isLoadingActive,
error: activeError,
} = useQuery({
queryKey: ["modules", "active"],
queryFn: moduleService.getActive,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
// Calcola i codici dei moduli attivi
const activeModuleCodes = activeModules.map((m) => m.code);
// Verifica se un modulo è abilitato
const isModuleEnabled = useCallback(
(code: string): boolean => {
return activeModuleCodes.includes(code);
},
[activeModuleCodes],
);
// Ottiene un modulo per codice
const getModule = useCallback(
(code: string): ModuleDto | undefined => {
return modules.find((m) => m.code === code);
},
[modules],
);
// Attiva un modulo
const enableModule = useCallback(
async (
code: string,
request: EnableModuleRequest,
): Promise<SubscriptionDto> => {
const subscription = await moduleService.enable(code, request);
// Invalida le query per ricaricare i dati
await queryClient.invalidateQueries({ queryKey: ["modules"] });
return subscription;
},
[queryClient],
);
// Disattiva un modulo
const disableModule = useCallback(
async (code: string): Promise<void> => {
await moduleService.disable(code);
// Invalida le query per ricaricare i dati
await queryClient.invalidateQueries({ queryKey: ["modules"] });
},
[queryClient],
);
// Ricarica i dati dei moduli
const refreshModules = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ["modules"] });
}, [queryClient]);
const value: ModuleContextValue = {
modules,
activeModules,
activeModuleCodes,
isLoading: isLoadingModules || isLoadingActive,
error: modulesError || activeError,
isModuleEnabled,
getModule,
enableModule,
disableModule,
refreshModules,
};
return (
<ModuleContext.Provider value={value}>{children}</ModuleContext.Provider>
);
}
// ==================== HOOKS ====================
/**
* Hook per accedere al context dei moduli
*/
export function useModules(): ModuleContextValue {
const context = useContext(ModuleContext);
if (!context) {
throw new Error("useModules must be used within a ModuleProvider");
}
return context;
}
/**
* Hook per verificare se un singolo modulo è abilitato
*/
export function useModuleEnabled(code: string): boolean {
const { isModuleEnabled } = useModules();
return isModuleEnabled(code);
}
/**
* Hook per ottenere solo i moduli attivi
*/
export function useActiveModules(): ModuleDto[] {
const { activeModules } = useModules();
return activeModules;
}
/**
* Hook per ottenere i codici dei moduli attivi
*/
export function useActiveModuleCodes(): string[] {
const { activeModuleCodes } = useModules();
return activeModuleCodes;
}
/**
* Hook per ottenere un modulo specifico
*/
export function useModule(code: string): ModuleDto | undefined {
const { getModule } = useModules();
return getModule(code);
}

View File

@@ -0,0 +1,386 @@
import { useState } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import {
Box,
Card,
CardContent,
Typography,
Button,
ToggleButton,
ToggleButtonGroup,
Alert,
CircularProgress,
Chip,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Paper,
} from "@mui/material";
import {
CheckCircle as CheckIcon,
ArrowBack as BackIcon,
ShoppingCart as CartIcon,
CalendarMonth as MonthlyIcon,
CalendarToday as AnnualIcon,
Warning as WarningIcon,
} from "@mui/icons-material";
import { useModule, useModules } from "../contexts/ModuleContext";
import {
SubscriptionType,
formatPrice,
getSubscriptionTypeName,
} from "../types/module";
export default function ModulePurchasePage() {
const { code } = useParams<{ code: string }>();
const navigate = useNavigate();
const location = useLocation();
const module = useModule(code || "");
const { enableModule, isModuleEnabled } = useModules();
const [subscriptionType, setSubscriptionType] = useState<SubscriptionType>(
SubscriptionType.Annual,
);
const [autoRenew, setAutoRenew] = useState(true);
// Mutation per attivare il modulo
const enableMutation = useMutation({
mutationFn: async () => {
if (!code) throw new Error("Codice modulo mancante");
return enableModule(code, {
subscriptionType,
autoRenew,
});
},
onSuccess: () => {
// Redirect alla pagina originale o alla home del modulo
const from = (location.state as { from?: string })?.from;
navigate(from || module?.routePath || "/");
},
});
// Se il modulo è già abilitato, redirect
if (code && isModuleEnabled(code)) {
navigate(module?.routePath || "/");
return null;
}
if (!module) {
return (
<Box sx={{ p: 3, textAlign: "center" }}>
<Typography variant="h5" color="error" gutterBottom>
Modulo non trovato
</Typography>
<Typography color="text.secondary" paragraph>
Il modulo richiesto non esiste.
</Typography>
<Button
variant="contained"
startIcon={<BackIcon />}
onClick={() => navigate("/")}
>
Torna alla Home
</Button>
</Box>
);
}
// Calcola il prezzo in base al tipo di subscription
const price =
subscriptionType === SubscriptionType.Monthly
? module.monthlyPrice
: module.basePrice;
const priceLabel =
subscriptionType === SubscriptionType.Monthly ? "/mese" : "/anno";
// Calcola risparmio annuale
const annualSavings = module.monthlyPrice * 12 - module.basePrice;
const savingsPercent = Math.round(
(annualSavings / (module.monthlyPrice * 12)) * 100,
);
// Verifica dipendenze mancanti
const missingDependencies = module.dependencies.filter(
(dep) => !isModuleEnabled(dep),
);
return (
<Box sx={{ p: 3, maxWidth: 800, mx: "auto" }}>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Button
startIcon={<BackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 2 }}
>
Indietro
</Button>
<Typography variant="h4" gutterBottom>
Attiva Modulo
</Typography>
<Typography color="text.secondary">
Scegli il piano di abbonamento per il modulo {module.name}
</Typography>
</Box>
{/* Alert dipendenze mancanti */}
{missingDependencies.length > 0 && (
<Alert severity="warning" sx={{ mb: 3 }} icon={<WarningIcon />}>
<Typography variant="subtitle2" gutterBottom>
Questo modulo richiede i seguenti moduli che non sono attivi:
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
{missingDependencies.map((dep) => (
<Chip
key={dep}
label={dep}
size="small"
color="warning"
onClick={() => navigate(`/modules/purchase/${dep}`)}
/>
))}
</Box>
</Alert>
)}
{/* Card principale */}
<Card elevation={3}>
<CardContent sx={{ p: 4 }}>
{/* Info modulo */}
<Box sx={{ mb: 4 }}>
<Typography variant="h5" gutterBottom>
{module.name}
</Typography>
<Typography color="text.secondary" paragraph>
{module.description}
</Typography>
</Box>
<Divider sx={{ my: 3 }} />
{/* Selezione tipo abbonamento */}
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="medium">
Tipo di abbonamento
</Typography>
<ToggleButtonGroup
value={subscriptionType}
exclusive
onChange={(_, value) => value && setSubscriptionType(value)}
fullWidth
sx={{ mb: 2 }}
>
<ToggleButton value={SubscriptionType.Monthly}>
<Box sx={{ py: 1, textAlign: "center" }}>
<MonthlyIcon sx={{ mb: 0.5 }} />
<Typography variant="body2" display="block">
Mensile
</Typography>
<Typography variant="h6" color="primary">
{formatPrice(module.monthlyPrice)}
<Typography
component="span"
variant="body2"
color="text.secondary"
>
/mese
</Typography>
</Typography>
</Box>
</ToggleButton>
<ToggleButton value={SubscriptionType.Annual}>
<Box sx={{ py: 1, textAlign: "center" }}>
<AnnualIcon sx={{ mb: 0.5 }} />
<Typography variant="body2" display="block">
Annuale
</Typography>
<Typography variant="h6" color="primary">
{formatPrice(module.basePrice)}
<Typography
component="span"
variant="body2"
color="text.secondary"
>
/anno
</Typography>
</Typography>
{savingsPercent > 0 && (
<Chip
label={`Risparmi ${savingsPercent}%`}
size="small"
color="success"
sx={{ mt: 0.5 }}
/>
)}
</Box>
</ToggleButton>
</ToggleButtonGroup>
{/* Opzione auto-rinnovo */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<ToggleButton
value="autoRenew"
selected={autoRenew}
onChange={() => setAutoRenew(!autoRenew)}
size="small"
>
{autoRenew ? <CheckIcon /> : null}
</ToggleButton>
<Typography variant="body2">
Rinnovo automatico alla scadenza
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Riepilogo */}
<Paper
variant="outlined"
sx={{ p: 2, mb: 3, bgcolor: "action.hover" }}
>
<Typography variant="subtitle2" gutterBottom>
Riepilogo ordine
</Typography>
<Box
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
>
<Typography>Modulo {module.name}</Typography>
<Typography>{formatPrice(price)}</Typography>
</Box>
<Box
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
>
<Typography color="text.secondary">
Abbonamento{" "}
{getSubscriptionTypeName(subscriptionType).toLowerCase()}
</Typography>
<Typography color="text.secondary">{priceLabel}</Typography>
</Box>
<Divider sx={{ my: 1 }} />
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="h6">Totale</Typography>
<Typography variant="h6" color="primary">
{formatPrice(price)}
{priceLabel}
</Typography>
</Box>
</Paper>
{/* Errore */}
{enableMutation.isError && (
<Alert severity="error" sx={{ mb: 3 }}>
{(enableMutation.error as Error)?.message ||
"Errore durante l'attivazione del modulo"}
</Alert>
)}
{/* Pulsante attivazione */}
<Button
variant="contained"
size="large"
fullWidth
startIcon={
enableMutation.isPending ? (
<CircularProgress size={20} color="inherit" />
) : (
<CartIcon />
)
}
onClick={() => enableMutation.mutate()}
disabled={
enableMutation.isPending || missingDependencies.length > 0
}
>
{enableMutation.isPending
? "Attivazione in corso..."
: "Attiva Modulo"}
</Button>
{/* Note */}
<Typography
variant="caption"
color="text.secondary"
display="block"
textAlign="center"
sx={{ mt: 2 }}
>
Potrai disattivare il modulo in qualsiasi momento dalle
impostazioni. I dati inseriti rimarranno disponibili.
</Typography>
</CardContent>
</Card>
{/* Funzionalità incluse */}
<Card sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Funzionalità incluse
</Typography>
<List dense>
{getModuleFeatures(module.code).map((feature, index) => (
<ListItem key={index}>
<ListItemIcon sx={{ minWidth: 36 }}>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={feature} />
</ListItem>
))}
</List>
</CardContent>
</Card>
</Box>
);
}
// Helper per ottenere le funzionalità di un modulo
function getModuleFeatures(code: string): string[] {
const features: Record<string, string[]> = {
warehouse: [
"Gestione anagrafica articoli",
"Movimenti di magazzino (carico/scarico)",
"Giacenze in tempo reale",
"Valorizzazione scorte (FIFO, LIFO, medio ponderato)",
"Inventario e rettifiche",
"Report giacenze e movimenti",
],
purchases: [
"Gestione ordini a fornitore",
"DDT di entrata",
"Fatture passive",
"Scadenziario pagamenti",
"Analisi acquisti per fornitore/articolo",
"Storico prezzi di acquisto",
],
sales: [
"Gestione ordini cliente",
"DDT di uscita",
"Fatturazione elettronica",
"Scadenziario incassi",
"Analisi vendite per cliente/articolo",
"Listini prezzi",
],
production: [
"Distinte base multilivello",
"Cicli di lavoro",
"Ordini di produzione",
"Pianificazione MRP",
"Avanzamento produzione",
"Costi di produzione",
],
quality: [
"Piani di controllo",
"Registrazione controlli",
"Gestione non conformità",
"Azioni correttive/preventive",
"Certificazioni e audit",
"Statistiche qualità",
],
};
return features[code] || ["Funzionalità complete del modulo"];
}

View File

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

View File

@@ -0,0 +1,128 @@
import api from "./api";
import type {
ModuleDto,
ModuleStatusDto,
SubscriptionDto,
EnableModuleRequest,
UpdateSubscriptionRequest,
RenewSubscriptionRequest,
} from "../types/module";
/**
* Service per la gestione dei moduli applicativi
*/
export const moduleService = {
/**
* Ottiene tutti i moduli disponibili con stato subscription
*/
getAll: async (): Promise<ModuleDto[]> => {
const response = await api.get("/modules");
return response.data;
},
/**
* Ottiene solo i moduli attivi (per costruzione menu)
*/
getActive: async (): Promise<ModuleDto[]> => {
const response = await api.get("/modules/active");
return response.data;
},
/**
* Ottiene un modulo specifico per codice
*/
getByCode: async (code: string): Promise<ModuleDto> => {
const response = await api.get(`/modules/${code}`);
return response.data;
},
/**
* Verifica se un modulo è abilitato
*/
isEnabled: async (code: string): Promise<ModuleStatusDto> => {
const response = await api.get(`/modules/${code}/enabled`);
return response.data;
},
/**
* Attiva un modulo
*/
enable: async (code: string, request: EnableModuleRequest): Promise<SubscriptionDto> => {
const response = await api.put(`/modules/${code}/enable`, request);
return response.data;
},
/**
* Disattiva un modulo
*/
disable: async (code: string): Promise<{ message: string }> => {
const response = await api.put(`/modules/${code}/disable`);
return response.data;
},
/**
* Ottiene tutte le subscription
*/
getAllSubscriptions: async (): Promise<SubscriptionDto[]> => {
const response = await api.get("/modules/subscriptions");
return response.data;
},
/**
* Aggiorna la subscription di un modulo
*/
updateSubscription: async (
code: string,
request: UpdateSubscriptionRequest
): Promise<SubscriptionDto> => {
const response = await api.put(`/modules/${code}/subscription`, request);
return response.data;
},
/**
* Rinnova la subscription di un modulo
*/
renewSubscription: async (
code: string,
request?: RenewSubscriptionRequest
): Promise<SubscriptionDto> => {
const response = await api.post(`/modules/${code}/subscription/renew`, request || {});
return response.data;
},
/**
* Ottiene i moduli in scadenza
*/
getExpiring: async (daysThreshold: number = 30): Promise<ModuleDto[]> => {
const response = await api.get("/modules/expiring", {
params: { daysThreshold },
});
return response.data;
},
/**
* Inizializza i moduli di default (admin)
*/
seedDefault: async (): Promise<{ message: string }> => {
const response = await api.post("/modules/seed");
return response.data;
},
/**
* Forza il controllo delle subscription scadute (admin)
*/
checkExpired: async (): Promise<{ message: string }> => {
const response = await api.post("/modules/check-expired");
return response.data;
},
/**
* Invalida la cache dei moduli (admin)
*/
invalidateCache: async (): Promise<{ message: string }> => {
const response = await api.post("/modules/invalidate-cache");
return response.data;
},
};
export default moduleService;

View File

@@ -0,0 +1,174 @@
/**
* Tipi di subscription per i moduli
*/
export enum SubscriptionType {
None = 0,
Monthly = 1,
Annual = 2,
}
/**
* Informazioni sulla subscription di un modulo
*/
export interface SubscriptionDto {
id: number;
moduleId: number;
moduleCode?: string;
moduleName?: string;
isEnabled: boolean;
subscriptionType: SubscriptionType;
subscriptionTypeName: string;
startDate?: string;
endDate?: string;
autoRenew: boolean;
notes?: string;
lastRenewalDate?: string;
paidPrice?: number;
isValid: boolean;
daysRemaining?: number;
isExpiringSoon: boolean;
}
/**
* Rappresenta un modulo dell'applicazione
*/
export interface ModuleDto {
id: number;
code: string;
name: string;
description?: string;
icon?: string;
basePrice: number;
monthlyPrice: number;
monthlyMultiplier: number;
sortOrder: number;
isCore: boolean;
dependencies: string[];
routePath?: string;
isAvailable: boolean;
isEnabled: boolean;
subscription?: SubscriptionDto;
}
/**
* Stato di un modulo (risposta da /api/modules/{code}/enabled)
*/
export interface ModuleStatusDto {
code: string;
isEnabled: boolean;
hasValidSubscription: boolean;
isCore: boolean;
daysRemaining?: number;
isExpiringSoon: boolean;
}
/**
* Request per attivare un modulo
*/
export interface EnableModuleRequest {
subscriptionType?: SubscriptionType;
startDate?: string;
endDate?: string;
autoRenew?: boolean;
paidPrice?: number;
notes?: string;
}
/**
* Request per aggiornare una subscription
*/
export interface UpdateSubscriptionRequest {
subscriptionType?: SubscriptionType;
endDate?: string;
autoRenew?: boolean;
notes?: string;
}
/**
* Request per rinnovare una subscription
*/
export interface RenewSubscriptionRequest {
paidPrice?: number;
}
/**
* Helper per ottenere il nome visualizzato del tipo subscription
*/
export function getSubscriptionTypeName(type: SubscriptionType): string {
switch (type) {
case SubscriptionType.None:
return "Nessuno";
case SubscriptionType.Monthly:
return "Mensile";
case SubscriptionType.Annual:
return "Annuale";
default:
return "Sconosciuto";
}
}
/**
* Helper per formattare il prezzo
*/
export function formatPrice(price: number): string {
return new Intl.NumberFormat("it-IT", {
style: "currency",
currency: "EUR",
}).format(price);
}
/**
* Helper per formattare la data
*/
export function formatDate(dateString?: string): string {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("it-IT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
/**
* Helper per calcolare i giorni rimanenti
*/
export function getDaysRemainingText(days?: number): string {
if (days === undefined || days === null) return "Nessuna scadenza";
if (days === 0) return "Scaduto";
if (days === 1) return "1 giorno";
return `${days} giorni`;
}
/**
* Helper per ottenere il colore dello stato subscription
*/
export function getSubscriptionStatusColor(
subscription?: SubscriptionDto
): "success" | "warning" | "error" | "default" {
if (!subscription || !subscription.isEnabled) return "default";
if (!subscription.isValid) return "error";
if (subscription.isExpiringSoon) return "warning";
return "success";
}
/**
* Helper per ottenere il testo dello stato subscription
*/
export function getSubscriptionStatusText(subscription?: SubscriptionDto): string {
if (!subscription) return "Non attivo";
if (!subscription.isEnabled) return "Disattivato";
if (!subscription.isValid) return "Scaduto";
if (subscription.isExpiringSoon) return "In scadenza";
return "Attivo";
}
/**
* Mappa delle icone MUI per i moduli
*/
export const moduleIcons: Record<string, string> = {
warehouse: "Warehouse",
purchases: "ShoppingCart",
sales: "PointOfSale",
production: "PrecisionManufacturing",
quality: "VerifiedUser",
};