-
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user