From 6e427e0199ec2b9915933d0e9a9e85d29a7a1b14 Mon Sep 17 00:00:00 2001 From: dnviti Date: Wed, 3 Dec 2025 01:31:25 +0100 Subject: [PATCH] feat: implement module purchase dialog with subscription type selection, auto-renew, and dependency checks, replacing the dedicated purchase page. --- .../Controllers/ModulesController.cs | 2 +- .../Zentral.API/Services/ModuleService.cs | 4 +- .../Entities/ModuleSubscription.cs | 3 - .../public/locales/it/translation.json | 1 + .../src/components/ModulePurchaseDialog.tsx | 301 ++++++++++++++++++ src/frontend/src/pages/ModulePurchasePage.tsx | 8 +- src/frontend/src/pages/ModulesAdminPage.tsx | 59 +++- 7 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 src/frontend/src/components/ModulePurchaseDialog.tsx diff --git a/src/backend/Zentral.API/Controllers/ModulesController.cs b/src/backend/Zentral.API/Controllers/ModulesController.cs index 206e301..d2c9a8a 100644 --- a/src/backend/Zentral.API/Controllers/ModulesController.cs +++ b/src/backend/Zentral.API/Controllers/ModulesController.cs @@ -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 }; } diff --git a/src/backend/Zentral.API/Services/ModuleService.cs b/src/backend/Zentral.API/Services/ModuleService.cs index 78c6386..9964615 100644 --- a/src/backend/Zentral.API/Services/ModuleService.cs +++ b/src/backend/Zentral.API/Services/ModuleService.cs @@ -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); } /// @@ -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))}"); diff --git a/src/backend/Zentral.Domain/Entities/ModuleSubscription.cs b/src/backend/Zentral.Domain/Entities/ModuleSubscription.cs index 59c81ad..8fa52f1 100644 --- a/src/backend/Zentral.Domain/Entities/ModuleSubscription.cs +++ b/src/backend/Zentral.Domain/Entities/ModuleSubscription.cs @@ -72,9 +72,6 @@ public class ModuleSubscription : BaseEntity /// public bool IsValid() { - if (!IsEnabled) - return false; - // Se non c'è data di scadenza, è valido (licenza perpetua o core module) if (!EndDate.HasValue) return true; diff --git a/src/frontend/public/locales/it/translation.json b/src/frontend/public/locales/it/translation.json index 5e9ae3e..0a6d047 100644 --- a/src/frontend/public/locales/it/translation.json +++ b/src/frontend/public/locales/it/translation.json @@ -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", diff --git a/src/frontend/src/components/ModulePurchaseDialog.tsx b/src/frontend/src/components/ModulePurchaseDialog.tsx new file mode 100644 index 0000000..b71074c --- /dev/null +++ b/src/frontend/src/components/ModulePurchaseDialog.tsx @@ -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.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 ( + + + + + {t("modules.admin.purchaseTitle")} - {module.name} + + + + + + + {module.description} + + + + {/* Alert dipendenze mancanti */} + {missingDependencies.length > 0 && ( + }> + + {t("modules.admin.missingDependencies")} + + + {missingDependencies.map((dep) => ( + + ))} + + + )} + + {/* Selezione tipo abbonamento */} + + + {t("modules.admin.subscriptionType")} + + value && setSubscriptionType(value)} + fullWidth + sx={{ mb: 2 }} + > + + + + + {t("modules.admin.monthly")} + + + {formatPrice(module.monthlyPrice)} + + {t("modules.admin.perMonth")} + + + + + + + + + {t("modules.admin.annual")} + + + {formatPrice(module.basePrice)} + + {t("modules.admin.perYear")} + + + {savingsPercent > 0 && ( + + )} + + + + + {/* Opzione auto-rinnovo */} + + setAutoRenew(!autoRenew)} + size="small" + > + {autoRenew ? : null} + + + {t("modules.admin.autoRenewLabel")} + + + + + + + {/* Riepilogo */} + + + {t("modules.admin.orderSummary")} + + + {t("modules.admin.module")} {module.name} + {formatPrice(price)} + + + + {t("modules.admin.subscription")}{" "} + {getSubscriptionTypeName(subscriptionType).toLowerCase()} + + {priceLabel} + + + + {t("modules.admin.total")} + + {formatPrice(price)} + {priceLabel} + + + + + {/* Errore */} + {enableMutation.isError && ( + + {(enableMutation.error as Error)?.message || + t("modules.admin.activationError")} + + )} + + {/* Funzionalità incluse */} + + + {t("modules.admin.includedFeatures")} + + + {getModuleFeatures(module.code, t).map((feature, index) => ( + + + + + + + ))} + + + + + + + + + ); +} + +// 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")]; +} diff --git a/src/frontend/src/pages/ModulePurchasePage.tsx b/src/frontend/src/pages/ModulePurchasePage.tsx index 1862693..8fbcbd6 100644 --- a/src/frontend/src/pages/ModulePurchasePage.tsx +++ b/src/frontend/src/pages/ModulePurchasePage.tsx @@ -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"); }, }); diff --git a/src/frontend/src/pages/ModulesAdminPage.tsx b/src/frontend/src/pages/ModulesAdminPage.tsx index 039c8b9..be80591 100644 --- a/src/frontend/src/pages/ModulesAdminPage.tsx +++ b/src/frontend/src/pages/ModulesAdminPage.tsx @@ -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(null); + const [purchaseModule, setPurchaseModule] = useState(null); const [confirmDisable, setConfirmDisable] = useState(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() { {disableMutation.isError && ( - {(disableMutation.error as Error)?.message || + {(disableMutation.error as AxiosError<{ message: string }>)?.response + ?.data?.message || + (disableMutation.error as Error)?.message || t("common.error")} )} @@ -358,7 +379,17 @@ export default function ModulesAdminPage() { - + + {/* Dialog acquisto modulo */} + { + setPurchaseModule(null); + refreshModules(); + }} + /> + ); } @@ -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({ {module.isCore ? ( + ) : !module.subscription?.isValid ? ( + } + onClick={onPurchase} + sx={{ cursor: "pointer" }} + /> ) : ( )} @@ -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" /> )}