feat: bundle and activate module dependencies during purchase, updating dialog UI and adding activation error handling.

This commit is contained in:
2025-12-03 01:43:05 +01:00
parent 51a8327a8d
commit 52ab4a5998
2 changed files with 168 additions and 34 deletions

View File

@@ -22,10 +22,9 @@ import {
} from "@mui/material"; } from "@mui/material";
import { import {
CheckCircle as CheckIcon, CheckCircle as CheckIcon,
ShoppingCart as CartIcon, ShoppingCart as ShoppingCartIcon,
CalendarMonth as MonthlyIcon, CalendarMonth as MonthlyIcon,
CalendarToday as AnnualIcon, CalendarToday as AnnualIcon,
Warning as WarningIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useModules } from "../contexts/ModuleContext"; import { useModules } from "../contexts/ModuleContext";
@@ -47,7 +46,7 @@ export default function ModulePurchaseDialog({
open, open,
onClose, onClose,
}: ModulePurchaseDialogProps) { }: ModulePurchaseDialogProps) {
const { enableModule, isModuleEnabled } = useModules(); const { modules, enableModule, isModuleEnabled } = useModules();
const { t } = useTranslation(); const { t } = useTranslation();
const [subscriptionType, setSubscriptionType] = useState<SubscriptionType>( const [subscriptionType, setSubscriptionType] = useState<SubscriptionType>(
@@ -55,14 +54,96 @@ export default function ModulePurchaseDialog({
); );
const [autoRenew, setAutoRenew] = useState(true); const [autoRenew, setAutoRenew] = useState(true);
// Mutation per attivare il modulo // Trova tutte le dipendenze mancanti ricorsivamente
const getAllMissingDependencies = (
moduleCode: string,
checked: Set<string> = new Set()
): ModuleDto[] => {
if (checked.has(moduleCode)) return [];
checked.add(moduleCode);
const module = modules.find((m) => m.code === moduleCode);
if (!module) return [];
let missing: ModuleDto[] = [];
// Se il modulo corrente non ha una subscription valida, va aggiunto
// (ma non se è il modulo target iniziale, che gestiamo a parte)
if (
module.code !== (module?.code || "") &&
!module.subscription?.isValid &&
!module.isCore
) {
missing.push(module);
}
// Controlla le dipendenze di questo modulo
for (const depCode of module.dependencies) {
// Se la dipendenza è già valida, non serve controllarla ricorsivamente
// (a meno che non vogliamo essere sicuri al 100%, ma assumiamo che se è valida è ok)
// Però se è valida ma disabilitata? Il backend richiede che sia ABILITATA.
// Quindi controlliamo isModuleEnabled(depCode).
if (!isModuleEnabled(depCode)) {
// Se non è abilitata, potrebbe essere valida ma spenta, o non valida.
// Se è valida ma spenta, dobbiamo solo abilitarla? O il backend lo fa?
// Il backend EnableModule richiede che le dipendenze siano ATTIVE.
// Quindi se è valida ma spenta, dobbiamo riattivarla.
// Se non è valida, dobbiamo acquistarla.
// Per semplicità, consideriamo "missing" qualsiasi cosa non abilitata.
// Ma dobbiamo distinguere tra "da acquistare" e "da attivare".
// Qui ci concentriamo sull'acquisto.
const depModule = modules.find((m) => m.code === depCode);
if (depModule) {
if (!depModule.subscription?.isValid && !depModule.isCore) {
missing.push(depModule);
}
// Ricorsione
missing = [...missing, ...getAllMissingDependencies(depCode, checked)];
}
}
}
// Rimuovi duplicati
return Array.from(new Set(missing.map((m) => m.code)))
.map((code) => modules.find((m) => m.code === code)!)
.filter(Boolean);
};
const missingDependencies = module ? getAllMissingDependencies(module.code) : [];
const modulesToPurchase = module ? [module, ...missingDependencies] : [];
// Calcola il prezzo totale
const calculateTotal = (type: SubscriptionType) => {
return modulesToPurchase.reduce((total, m) => {
return (
total +
(type === SubscriptionType.Monthly ? m.monthlyPrice : m.basePrice)
);
}, 0);
};
const totalPrice = calculateTotal(subscriptionType);
// Mutation per attivare il modulo e le dipendenze
const enableMutation = useMutation({ const enableMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!module?.code) throw new Error("Codice modulo mancante"); if (!module?.code) throw new Error("Codice modulo mancante");
return enableModule(module.code, {
subscriptionType, // Attiva prima le dipendenze (in ordine inverso di dipendenza sarebbe meglio,
autoRenew, // ma qui le abbiamo piatte. Dobbiamo ordinarle o sperare che l'ordine sia corretto.
}); // I moduli base (es. warehouse) dovrebbero essere attivati prima.
// Ordiniamo per SortOrder o livello di dipendenza.
// Assumiamo che SortOrder rifletta la gerarchia (Warehouse=10, Purchases=20...).
const sortedModules = [...modulesToPurchase].sort(
(a, b) => a.sortOrder - b.sortOrder
);
for (const m of sortedModules) {
await enableModule(m.code, {
subscriptionType,
autoRenew,
});
}
}, },
onSuccess: () => { onSuccess: () => {
onClose(); onClose();
@@ -71,11 +152,7 @@ export default function ModulePurchaseDialog({
if (!module) return null; if (!module) return null;
// Calcola il prezzo in base al tipo di subscription
const price =
subscriptionType === SubscriptionType.Monthly
? module.monthlyPrice
: module.basePrice;
const priceLabel = const priceLabel =
subscriptionType === SubscriptionType.Monthly subscriptionType === SubscriptionType.Monthly
@@ -83,14 +160,14 @@ export default function ModulePurchaseDialog({
: t("modules.admin.perYear"); : t("modules.admin.perYear");
// Calcola risparmio annuale // Calcola risparmio annuale
const annualSavings = module.monthlyPrice * 12 - module.basePrice; const annualSavings = modulesToPurchase.reduce((acc, m) => {
const savingsPercent = Math.round( return acc + (m.monthlyPrice * 12 - m.basePrice);
(annualSavings / (module.monthlyPrice * 12)) * 100 }, 0);
);
// Verifica dipendenze mancanti const totalMonthlyPrice = modulesToPurchase.reduce((acc, m) => acc + m.monthlyPrice, 0);
const missingDependencies = module.dependencies.filter(
(dep) => !isModuleEnabled(dep) const savingsPercent = Math.round(
(annualSavings / (totalMonthlyPrice * 12)) * 100
); );
return ( return (
@@ -109,19 +186,27 @@ export default function ModulePurchaseDialog({
</Typography> </Typography>
</Box> </Box>
{/* Alert dipendenze mancanti */} {/* Alert dipendenze incluse */}
{missingDependencies.length > 0 && ( {missingDependencies.length > 0 && (
<Alert severity="warning" sx={{ mb: 3 }} icon={<WarningIcon />}> <Alert severity="info" sx={{ mb: 3 }} icon={<ShoppingCartIcon />}>
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
{t("modules.admin.missingDependencies")} {t("modules.admin.dependenciesIncluded")}
</Typography>
<Typography variant="body2" paragraph>
{t("modules.admin.dependenciesIncludedText")}
</Typography> </Typography>
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
{missingDependencies.map((dep) => ( {missingDependencies.map((dep) => (
<Chip <Chip
key={dep} key={dep.code}
label={dep} label={`${dep.name} (+${formatPrice(
subscriptionType === SubscriptionType.Monthly
? dep.monthlyPrice
: dep.basePrice
)})`}
size="small" size="small"
color="warning" color="info"
variant="outlined"
/> />
))} ))}
</Box> </Box>
@@ -147,7 +232,7 @@ export default function ModulePurchaseDialog({
{t("modules.admin.monthly")} {t("modules.admin.monthly")}
</Typography> </Typography>
<Typography variant="h6" color="primary"> <Typography variant="h6" color="primary">
{formatPrice(module.monthlyPrice)} {formatPrice(calculateTotal(SubscriptionType.Monthly))}
<Typography <Typography
component="span" component="span"
variant="body2" variant="body2"
@@ -165,7 +250,7 @@ export default function ModulePurchaseDialog({
{t("modules.admin.annual")} {t("modules.admin.annual")}
</Typography> </Typography>
<Typography variant="h6" color="primary"> <Typography variant="h6" color="primary">
{formatPrice(module.basePrice)} {formatPrice(calculateTotal(SubscriptionType.Annual))}
<Typography <Typography
component="span" component="span"
variant="body2" variant="body2"
@@ -218,8 +303,31 @@ export default function ModulePurchaseDialog({
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }} sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
> >
<Typography>{t("modules.admin.module")} {module.name}</Typography> <Typography>{t("modules.admin.module")} {module.name}</Typography>
<Typography>{formatPrice(price)}</Typography> <Typography>
{formatPrice(
subscriptionType === SubscriptionType.Monthly
? module.monthlyPrice
: module.basePrice
)}
</Typography>
</Box> </Box>
{missingDependencies.map((dep) => (
<Box
key={dep.code}
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
>
<Typography color="text.secondary">
+ {dep.name}
</Typography>
<Typography color="text.secondary">
{formatPrice(
subscriptionType === SubscriptionType.Monthly
? dep.monthlyPrice
: dep.basePrice
)}
</Typography>
</Box>
))}
<Box <Box
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }} sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
> >
@@ -233,7 +341,7 @@ export default function ModulePurchaseDialog({
<Box sx={{ display: "flex", justifyContent: "space-between" }}> <Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="h6">{t("modules.admin.total")}</Typography> <Typography variant="h6">{t("modules.admin.total")}</Typography>
<Typography variant="h6" color="primary"> <Typography variant="h6" color="primary">
{formatPrice(price)} {formatPrice(totalPrice)}
{priceLabel} {priceLabel}
</Typography> </Typography>
</Box> </Box>
@@ -274,13 +382,11 @@ export default function ModulePurchaseDialog({
enableMutation.isPending ? ( enableMutation.isPending ? (
<CircularProgress size={20} color="inherit" /> <CircularProgress size={20} color="inherit" />
) : ( ) : (
<CartIcon /> <ShoppingCartIcon />
) )
} }
onClick={() => enableMutation.mutate()} onClick={() => enableMutation.mutate()}
disabled={ disabled={enableMutation.isPending}
enableMutation.isPending || missingDependencies.length > 0
}
> >
{enableMutation.isPending {enableMutation.isPending
? t("modules.admin.activating") ? t("modules.admin.activating")

View File

@@ -51,6 +51,7 @@ export default function ModulesAdminPage() {
const [selectedModule, setSelectedModule] = useState<ModuleDto | null>(null); const [selectedModule, setSelectedModule] = useState<ModuleDto | null>(null);
const [purchaseModule, setPurchaseModule] = useState<ModuleDto | null>(null); const [purchaseModule, setPurchaseModule] = useState<ModuleDto | null>(null);
const [confirmDisable, setConfirmDisable] = useState<string | null>(null); const [confirmDisable, setConfirmDisable] = useState<string | null>(null);
const [enableError, setEnableError] = useState<string | null>(null);
// Query per moduli in scadenza // Query per moduli in scadenza
const { data: expiringModules = [] } = useQuery({ const { data: expiringModules = [] } = useQuery({
@@ -77,6 +78,13 @@ export default function ModulesAdminPage() {
onSuccess: () => { onSuccess: () => {
refreshModules(); refreshModules();
}, },
onError: (error) => {
setEnableError(
(error as AxiosError<{ message: string }>)?.response?.data?.message ||
(error as Error)?.message ||
t("common.error")
);
},
}); });
// Mutation per rinnovare subscription // Mutation per rinnovare subscription
@@ -380,6 +388,26 @@ export default function ModulesAdminPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Dialog errore attivazione */}
<Dialog
open={!!enableError}
onClose={() => setEnableError(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>{t("common.error")}</DialogTitle>
<DialogContent>
<Alert severity="error" sx={{ mt: 1 }}>
{enableError}
</Alert>
</DialogContent>
<DialogActions>
<Button onClick={() => setEnableError(null)}>
{t("common.close")}
</Button>
</DialogActions>
</Dialog>
{/* Dialog acquisto modulo */} {/* Dialog acquisto modulo */}
<ModulePurchaseDialog <ModulePurchaseDialog
module={purchaseModule} module={purchaseModule}