feat: bundle and activate module dependencies during purchase, updating dialog UI and adding activation error handling.
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user