feat: implement module purchase dialog with subscription type selection, auto-renew, and dependency checks, replacing the dedicated purchase page.

This commit is contained in:
2025-12-03 01:31:25 +01:00
parent a4e0c276c6
commit 6e427e0199
7 changed files with 361 additions and 17 deletions

View File

@@ -244,7 +244,7 @@ public class ModulesController : ControllerBase
Dependencies = module.GetDependencies().ToList(),
RoutePath = module.RoutePath,
IsAvailable = module.IsAvailable,
IsEnabled = module.IsCore || (module.Subscription?.IsValid() ?? false),
IsEnabled = module.IsCore || ((module.Subscription?.IsEnabled ?? false) && (module.Subscription?.IsValid() ?? false)),
Subscription = module.Subscription != null ? MapSubscriptionToDto(module.Subscription) : null
};
}

View File

@@ -87,7 +87,7 @@ public class ModuleService
if (module.IsCore)
return true;
return module.Subscription?.IsValid() ?? false;
return (module.Subscription?.IsEnabled ?? false) && (module.Subscription?.IsValid() ?? false);
}
/// <summary>
@@ -200,7 +200,7 @@ public class ModuleService
// Verifica se altri moduli dipendono da questo
var dependentModules = await GetDependentModulesAsync(code);
var activeDependents = dependentModules.Where(m => m.Subscription?.IsValid() ?? false).ToList();
var activeDependents = dependentModules.Where(m => (m.Subscription?.IsEnabled ?? false) && (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))}");

View File

@@ -72,9 +72,6 @@ public class ModuleSubscription : BaseEntity
/// </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;

View File

@@ -271,6 +271,7 @@
"disableConfirmSubtext": "I dati inseriti rimarranno nel sistema ma non saranno più accessibili fino alla riattivazione.",
"disable": "Disattiva",
"enable": "Attiva",
"purchase": "Acquista",
"details": "Dettagli",
"renew": "Rinnova",
"active": "Attivo",

View File

@@ -0,0 +1,301 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Box,
Typography,
Button,
ToggleButton,
ToggleButtonGroup,
Alert,
CircularProgress,
Chip,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Paper,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import {
CheckCircle as CheckIcon,
ShoppingCart as CartIcon,
CalendarMonth as MonthlyIcon,
CalendarToday as AnnualIcon,
Warning as WarningIcon,
} from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import { useModules } from "../contexts/ModuleContext";
import {
ModuleDto,
SubscriptionType,
formatPrice,
getSubscriptionTypeName,
} from "../types/module";
interface ModulePurchaseDialogProps {
module: ModuleDto | null;
open: boolean;
onClose: () => void;
}
export default function ModulePurchaseDialog({
module,
open,
onClose,
}: ModulePurchaseDialogProps) {
const { enableModule, isModuleEnabled } = useModules();
const { t } = useTranslation();
const [subscriptionType, setSubscriptionType] = useState<SubscriptionType>(
SubscriptionType.Annual
);
const [autoRenew, setAutoRenew] = useState(true);
// Mutation per attivare il modulo
const enableMutation = useMutation({
mutationFn: async () => {
if (!module?.code) throw new Error("Codice modulo mancante");
return enableModule(module.code, {
subscriptionType,
autoRenew,
});
},
onSuccess: () => {
onClose();
},
});
if (!module) return null;
// Calcola il prezzo in base al tipo di subscription
const price =
subscriptionType === SubscriptionType.Monthly
? module.monthlyPrice
: module.basePrice;
const priceLabel =
subscriptionType === SubscriptionType.Monthly
? t("modules.admin.perMonth")
: t("modules.admin.perYear");
// 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 (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6">
{t("modules.admin.purchaseTitle")} - {module.name}
</Typography>
</Box>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ mb: 3 }}>
<Typography color="text.secondary" paragraph>
{module.description}
</Typography>
</Box>
{/* Alert dipendenze mancanti */}
{missingDependencies.length > 0 && (
<Alert severity="warning" sx={{ mb: 3 }} icon={<WarningIcon />}>
<Typography variant="subtitle2" gutterBottom>
{t("modules.admin.missingDependencies")}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
{missingDependencies.map((dep) => (
<Chip
key={dep}
label={dep}
size="small"
color="warning"
/>
))}
</Box>
</Alert>
)}
{/* Selezione tipo abbonamento */}
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom fontWeight="medium">
{t("modules.admin.subscriptionType")}
</Typography>
<ToggleButtonGroup
value={subscriptionType}
exclusive
onChange={(_, value) => value && setSubscriptionType(value)}
fullWidth
sx={{ mb: 2 }}
>
<ToggleButton value={SubscriptionType.Monthly}>
<Box sx={{ py: 1, textAlign: "center", width: "100%" }}>
<MonthlyIcon sx={{ mb: 0.5 }} />
<Typography variant="body2" display="block">
{t("modules.admin.monthly")}
</Typography>
<Typography variant="h6" color="primary">
{formatPrice(module.monthlyPrice)}
<Typography
component="span"
variant="body2"
color="text.secondary"
>
{t("modules.admin.perMonth")}
</Typography>
</Typography>
</Box>
</ToggleButton>
<ToggleButton value={SubscriptionType.Annual}>
<Box sx={{ py: 1, textAlign: "center", width: "100%" }}>
<AnnualIcon sx={{ mb: 0.5 }} />
<Typography variant="body2" display="block">
{t("modules.admin.annual")}
</Typography>
<Typography variant="h6" color="primary">
{formatPrice(module.basePrice)}
<Typography
component="span"
variant="body2"
color="text.secondary"
>
{t("modules.admin.perYear")}
</Typography>
</Typography>
{savingsPercent > 0 && (
<Chip
label={t("modules.admin.savings", {
percent: 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">
{t("modules.admin.autoRenewLabel")}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Riepilogo */}
<Paper
variant="outlined"
sx={{ p: 2, mb: 3, bgcolor: "action.hover" }}
>
<Typography variant="subtitle2" gutterBottom>
{t("modules.admin.orderSummary")}
</Typography>
<Box
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
>
<Typography>{t("modules.admin.module")} {module.name}</Typography>
<Typography>{formatPrice(price)}</Typography>
</Box>
<Box
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
>
<Typography color="text.secondary">
{t("modules.admin.subscription")}{" "}
{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">{t("modules.admin.total")}</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 ||
t("modules.admin.activationError")}
</Alert>
)}
{/* Funzionalità incluse */}
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" gutterBottom>
{t("modules.admin.includedFeatures")}
</Typography>
<List dense>
{getModuleFeatures(module.code, t).map((feature, index) => (
<ListItem key={index} disablePadding>
<ListItemIcon sx={{ minWidth: 36 }}>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={feature} />
</ListItem>
))}
</List>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={enableMutation.isPending}>
{t("common.cancel")}
</Button>
<Button
variant="contained"
startIcon={
enableMutation.isPending ? (
<CircularProgress size={20} color="inherit" />
) : (
<CartIcon />
)
}
onClick={() => enableMutation.mutate()}
disabled={
enableMutation.isPending || missingDependencies.length > 0
}
>
{enableMutation.isPending
? t("modules.admin.activating")
: t("modules.admin.activateModule")}
</Button>
</DialogActions>
</Dialog>
);
}
// Helper per ottenere le funzionalità di un modulo
function getModuleFeatures(code: string, t: (key: string) => string): string[] {
const featureKeys = [0, 1, 2, 3, 4, 5];
if (["warehouse", "purchases", "sales", "production", "quality"].includes(code)) {
return featureKeys.map((i) => t(`modules.features.${code}.${i}`));
}
return [t("modules.features.default")];
}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import {
Box,
@@ -38,7 +38,6 @@ import {
export default function ModulePurchasePage() {
const { code } = useParams<{ code: string }>();
const navigate = useNavigate();
const location = useLocation();
const module = useModule(code || "");
const { enableModule, isModuleEnabled } = useModules();
const { t } = useTranslation();
@@ -58,9 +57,8 @@ export default function ModulePurchasePage() {
});
},
onSuccess: () => {
// Redirect alla pagina originale o alla home del modulo
const from = (location.state as { from?: string })?.from;
navigate(from || module?.routePath || "/");
// Redirect alla pagina dei moduli
navigate("/modules");
},
});

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Box,
@@ -28,8 +27,10 @@ import {
Warning as WarningIcon,
Schedule as ScheduleIcon,
Autorenew as RenewIcon,
ShoppingCart,
} from "@mui/icons-material";
import * as Icons from "@mui/icons-material";
import { AxiosError } from "axios";
import { useTranslation } from "react-i18next";
import { useModules } from "../contexts/ModuleContext";
import { moduleService } from "../services/moduleService";
@@ -40,13 +41,15 @@ import {
getDaysRemainingText,
getSubscriptionStatusColor,
getSubscriptionStatusText,
SubscriptionType,
} from "../types/module";
import ModulePurchaseDialog from "../components/ModulePurchaseDialog";
export default function ModulesAdminPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const { modules, isLoading, refreshModules } = useModules();
const [selectedModule, setSelectedModule] = useState<ModuleDto | null>(null);
const [purchaseModule, setPurchaseModule] = useState<ModuleDto | null>(null);
const [confirmDisable, setConfirmDisable] = useState<string | null>(null);
// Query per moduli in scadenza
@@ -64,6 +67,18 @@ export default function ModulesAdminPage() {
},
});
// Mutation per attivare modulo
const enableMutation = useMutation({
mutationFn: (code: string) =>
moduleService.enable(code, {
subscriptionType: SubscriptionType.Annual,
autoRenew: true,
}),
onSuccess: () => {
refreshModules();
},
});
// Mutation per rinnovare subscription
const renewMutation = useMutation({
mutationFn: (code: string) => moduleService.renewSubscription(code),
@@ -166,9 +181,13 @@ export default function ModulesAdminPage() {
if (module.isEnabled && !module.isCore) {
setConfirmDisable(module.code);
} else if (!module.isEnabled) {
navigate(`/modules/purchase/${module.code}`);
// Se ha una subscription valida, lo abilitiamo direttamente
if (module.subscription?.isValid) {
enableMutation.mutate(module.code);
}
}
}}
onPurchase={() => setPurchaseModule(module)}
onInfo={() => setSelectedModule(module)}
onRenew={() => renewMutation.mutate(module.code)}
isRenewing={renewMutation.isPending}
@@ -332,7 +351,9 @@ export default function ModulesAdminPage() {
</Typography>
{disableMutation.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{(disableMutation.error as Error)?.message ||
{(disableMutation.error as AxiosError<{ message: string }>)?.response
?.data?.message ||
(disableMutation.error as Error)?.message ||
t("common.error")}
</Alert>
)}
@@ -358,7 +379,17 @@ export default function ModulesAdminPage() {
</Button>
</DialogActions>
</Dialog>
</Box>
{/* Dialog acquisto modulo */}
<ModulePurchaseDialog
module={purchaseModule}
open={!!purchaseModule}
onClose={() => {
setPurchaseModule(null);
refreshModules();
}}
/>
</Box >
);
}
@@ -366,6 +397,7 @@ export default function ModulesAdminPage() {
interface ModuleCardProps {
module: ModuleDto;
onToggle: () => void;
onPurchase: () => void;
onInfo: () => void;
onRenew: () => void;
isRenewing: boolean;
@@ -376,6 +408,7 @@ interface ModuleCardProps {
function ModuleCard({
module,
onToggle,
onPurchase,
onInfo,
onRenew,
isRenewing,
@@ -421,6 +454,15 @@ function ModuleCard({
<Box>
{module.isCore ? (
<Chip label={t("modules.admin.core")} size="small" color="info" />
) : !module.subscription?.isValid ? (
<Chip
label={t("modules.admin.purchase")}
size="small"
color="error"
icon={<ShoppingCart />}
onClick={onPurchase}
sx={{ cursor: "pointer" }}
/>
) : (
<Chip label={statusText} size="small" color={statusColor} />
)}
@@ -514,9 +556,14 @@ function ModuleCard({
checked={module.isEnabled}
onChange={onToggle}
color={module.isEnabled ? "success" : "default"}
disabled={!module.subscription?.isValid}
/>
}
label={module.isEnabled ? t("modules.admin.active") : t("modules.admin.inactive")}
label={
module.isEnabled
? t("modules.admin.active")
: t("modules.admin.inactive")
}
labelPlacement="start"
/>
)}