516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|