-
This commit is contained in:
@@ -17,8 +17,11 @@ import RisorsePage from "./pages/RisorsePage";
|
||||
import CalendarioPage from "./pages/CalendarioPage";
|
||||
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
|
||||
import ReportEditorPage from "./pages/ReportEditorPage";
|
||||
import ModulesAdminPage from "./pages/ModulesAdminPage";
|
||||
import ModulePurchasePage from "./pages/ModulePurchasePage";
|
||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||
import { ModuleProvider } from "./contexts/ModuleContext";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -60,34 +63,42 @@ function App() {
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<CollaborationProvider>
|
||||
<RealTimeProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="calendario" element={<CalendarioPage />} />
|
||||
<Route path="eventi" element={<EventiPage />} />
|
||||
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
||||
<Route path="clienti" element={<ClientiPage />} />
|
||||
<Route path="location" element={<LocationPage />} />
|
||||
<Route path="articoli" element={<ArticoliPage />} />
|
||||
<Route path="risorse" element={<RisorsePage />} />
|
||||
<Route
|
||||
path="report-templates"
|
||||
element={<ReportTemplatesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor/:id"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</RealTimeProvider>
|
||||
</CollaborationProvider>
|
||||
<ModuleProvider>
|
||||
<CollaborationProvider>
|
||||
<RealTimeProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="calendario" element={<CalendarioPage />} />
|
||||
<Route path="eventi" element={<EventiPage />} />
|
||||
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
||||
<Route path="clienti" element={<ClientiPage />} />
|
||||
<Route path="location" element={<LocationPage />} />
|
||||
<Route path="articoli" element={<ArticoliPage />} />
|
||||
<Route path="risorse" element={<RisorsePage />} />
|
||||
<Route
|
||||
path="report-templates"
|
||||
element={<ReportTemplatesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor/:id"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
{/* Moduli */}
|
||||
<Route path="modules" element={<ModulesAdminPage />} />
|
||||
<Route
|
||||
path="modules/purchase/:code"
|
||||
element={<ModulePurchasePage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</RealTimeProvider>
|
||||
</CollaborationProvider>
|
||||
</ModuleProvider>
|
||||
</BrowserRouter>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
CalendarMonth as CalendarIcon,
|
||||
Print as PrintIcon,
|
||||
Close as CloseIcon,
|
||||
Extension as ModulesIcon,
|
||||
} from "@mui/icons-material";
|
||||
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
||||
|
||||
@@ -42,6 +43,7 @@ const menuItems = [
|
||||
{ text: "Articoli", icon: <InventoryIcon />, path: "/articoli" },
|
||||
{ text: "Risorse", icon: <PersonIcon />, path: "/risorse" },
|
||||
{ text: "Report", icon: <PrintIcon />, path: "/report-templates" },
|
||||
{ text: "Moduli", icon: <ModulesIcon />, path: "/modules" },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
|
||||
75
frontend/src/components/ModuleGuard.tsx
Normal file
75
frontend/src/components/ModuleGuard.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useModuleEnabled, useModule } from "../contexts/ModuleContext";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
interface ModuleGuardProps {
|
||||
/** Codice del modulo richiesto */
|
||||
moduleCode: string;
|
||||
/** Contenuto da renderizzare se il modulo è abilitato */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente guard che protegge le route basandosi sullo stato del modulo.
|
||||
* Se il modulo non è abilitato, reindirizza alla pagina di acquisto.
|
||||
*/
|
||||
export function ModuleGuard({ moduleCode, children }: ModuleGuardProps) {
|
||||
const location = useLocation();
|
||||
const isEnabled = useModuleEnabled(moduleCode);
|
||||
const module = useModule(moduleCode);
|
||||
|
||||
// Se il modulo non esiste (codice errato), mostra errore
|
||||
if (module === undefined) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "50vh",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" color="error">
|
||||
Modulo non trovato
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Il modulo "{moduleCode}" non esiste.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Se il modulo è abilitato, mostra il contenuto
|
||||
if (isEnabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Modulo non abilitato: redirect alla pagina di acquisto
|
||||
return (
|
||||
<Navigate
|
||||
to={`/modules/purchase/${moduleCode}`}
|
||||
state={{ from: location.pathname }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HOC per proteggere un componente con ModuleGuard
|
||||
*/
|
||||
export function withModuleGuard<P extends object>(
|
||||
WrappedComponent: React.ComponentType<P>,
|
||||
moduleCode: string,
|
||||
) {
|
||||
return function ModuleGuardedComponent(props: P) {
|
||||
return (
|
||||
<ModuleGuard moduleCode={moduleCode}>
|
||||
<WrappedComponent {...props} />
|
||||
</ModuleGuard>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ModuleGuard;
|
||||
182
frontend/src/contexts/ModuleContext.tsx
Normal file
182
frontend/src/contexts/ModuleContext.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { createContext, useContext, useCallback, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { moduleService } from "../services/moduleService";
|
||||
import type {
|
||||
ModuleDto,
|
||||
EnableModuleRequest,
|
||||
SubscriptionDto,
|
||||
} from "../types/module";
|
||||
|
||||
// ==================== TYPES ====================
|
||||
|
||||
export interface ModuleContextValue {
|
||||
/** Lista di tutti i moduli disponibili */
|
||||
modules: ModuleDto[];
|
||||
/** Lista dei moduli attualmente attivi */
|
||||
activeModules: ModuleDto[];
|
||||
/** Codici dei moduli attivi (per filtro veloce) */
|
||||
activeModuleCodes: string[];
|
||||
/** Stato di caricamento */
|
||||
isLoading: boolean;
|
||||
/** Errore di caricamento */
|
||||
error: Error | null;
|
||||
/** Verifica se un modulo è abilitato */
|
||||
isModuleEnabled: (code: string) => boolean;
|
||||
/** Ottiene un modulo per codice */
|
||||
getModule: (code: string) => ModuleDto | undefined;
|
||||
/** Attiva un modulo */
|
||||
enableModule: (
|
||||
code: string,
|
||||
request: EnableModuleRequest,
|
||||
) => Promise<SubscriptionDto>;
|
||||
/** Disattiva un modulo */
|
||||
disableModule: (code: string) => Promise<void>;
|
||||
/** Ricarica i dati dei moduli */
|
||||
refreshModules: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ModuleContext = createContext<ModuleContextValue | null>(null);
|
||||
|
||||
// ==================== PROVIDER ====================
|
||||
|
||||
interface ModuleProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ModuleProvider({ children }: ModuleProviderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Query per tutti i moduli
|
||||
const {
|
||||
data: modules = [],
|
||||
isLoading: isLoadingModules,
|
||||
error: modulesError,
|
||||
} = useQuery({
|
||||
queryKey: ["modules"],
|
||||
queryFn: moduleService.getAll,
|
||||
staleTime: 5 * 60 * 1000, // 5 minuti
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Query per moduli attivi
|
||||
const {
|
||||
data: activeModules = [],
|
||||
isLoading: isLoadingActive,
|
||||
error: activeError,
|
||||
} = useQuery({
|
||||
queryKey: ["modules", "active"],
|
||||
queryFn: moduleService.getActive,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Calcola i codici dei moduli attivi
|
||||
const activeModuleCodes = activeModules.map((m) => m.code);
|
||||
|
||||
// Verifica se un modulo è abilitato
|
||||
const isModuleEnabled = useCallback(
|
||||
(code: string): boolean => {
|
||||
return activeModuleCodes.includes(code);
|
||||
},
|
||||
[activeModuleCodes],
|
||||
);
|
||||
|
||||
// Ottiene un modulo per codice
|
||||
const getModule = useCallback(
|
||||
(code: string): ModuleDto | undefined => {
|
||||
return modules.find((m) => m.code === code);
|
||||
},
|
||||
[modules],
|
||||
);
|
||||
|
||||
// Attiva un modulo
|
||||
const enableModule = useCallback(
|
||||
async (
|
||||
code: string,
|
||||
request: EnableModuleRequest,
|
||||
): Promise<SubscriptionDto> => {
|
||||
const subscription = await moduleService.enable(code, request);
|
||||
// Invalida le query per ricaricare i dati
|
||||
await queryClient.invalidateQueries({ queryKey: ["modules"] });
|
||||
return subscription;
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Disattiva un modulo
|
||||
const disableModule = useCallback(
|
||||
async (code: string): Promise<void> => {
|
||||
await moduleService.disable(code);
|
||||
// Invalida le query per ricaricare i dati
|
||||
await queryClient.invalidateQueries({ queryKey: ["modules"] });
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Ricarica i dati dei moduli
|
||||
const refreshModules = useCallback(async (): Promise<void> => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["modules"] });
|
||||
}, [queryClient]);
|
||||
|
||||
const value: ModuleContextValue = {
|
||||
modules,
|
||||
activeModules,
|
||||
activeModuleCodes,
|
||||
isLoading: isLoadingModules || isLoadingActive,
|
||||
error: modulesError || activeError,
|
||||
isModuleEnabled,
|
||||
getModule,
|
||||
enableModule,
|
||||
disableModule,
|
||||
refreshModules,
|
||||
};
|
||||
|
||||
return (
|
||||
<ModuleContext.Provider value={value}>{children}</ModuleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== HOOKS ====================
|
||||
|
||||
/**
|
||||
* Hook per accedere al context dei moduli
|
||||
*/
|
||||
export function useModules(): ModuleContextValue {
|
||||
const context = useContext(ModuleContext);
|
||||
if (!context) {
|
||||
throw new Error("useModules must be used within a ModuleProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook per verificare se un singolo modulo è abilitato
|
||||
*/
|
||||
export function useModuleEnabled(code: string): boolean {
|
||||
const { isModuleEnabled } = useModules();
|
||||
return isModuleEnabled(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook per ottenere solo i moduli attivi
|
||||
*/
|
||||
export function useActiveModules(): ModuleDto[] {
|
||||
const { activeModules } = useModules();
|
||||
return activeModules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook per ottenere i codici dei moduli attivi
|
||||
*/
|
||||
export function useActiveModuleCodes(): string[] {
|
||||
const { activeModuleCodes } = useModules();
|
||||
return activeModuleCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook per ottenere un modulo specifico
|
||||
*/
|
||||
export function useModule(code: string): ModuleDto | undefined {
|
||||
const { getModule } = useModules();
|
||||
return getModule(code);
|
||||
}
|
||||
386
frontend/src/pages/ModulePurchasePage.tsx
Normal file
386
frontend/src/pages/ModulePurchasePage.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
CheckCircle as CheckIcon,
|
||||
ArrowBack as BackIcon,
|
||||
ShoppingCart as CartIcon,
|
||||
CalendarMonth as MonthlyIcon,
|
||||
CalendarToday as AnnualIcon,
|
||||
Warning as WarningIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useModule, useModules } from "../contexts/ModuleContext";
|
||||
import {
|
||||
SubscriptionType,
|
||||
formatPrice,
|
||||
getSubscriptionTypeName,
|
||||
} from "../types/module";
|
||||
|
||||
export default function ModulePurchasePage() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const module = useModule(code || "");
|
||||
const { enableModule, isModuleEnabled } = useModules();
|
||||
|
||||
const [subscriptionType, setSubscriptionType] = useState<SubscriptionType>(
|
||||
SubscriptionType.Annual,
|
||||
);
|
||||
const [autoRenew, setAutoRenew] = useState(true);
|
||||
|
||||
// Mutation per attivare il modulo
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!code) throw new Error("Codice modulo mancante");
|
||||
return enableModule(code, {
|
||||
subscriptionType,
|
||||
autoRenew,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Redirect alla pagina originale o alla home del modulo
|
||||
const from = (location.state as { from?: string })?.from;
|
||||
navigate(from || module?.routePath || "/");
|
||||
},
|
||||
});
|
||||
|
||||
// Se il modulo è già abilitato, redirect
|
||||
if (code && isModuleEnabled(code)) {
|
||||
navigate(module?.routePath || "/");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!module) {
|
||||
return (
|
||||
<Box sx={{ p: 3, textAlign: "center" }}>
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
Modulo non trovato
|
||||
</Typography>
|
||||
<Typography color="text.secondary" paragraph>
|
||||
Il modulo richiesto non esiste.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Torna alla Home
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Calcola il prezzo in base al tipo di subscription
|
||||
const price =
|
||||
subscriptionType === SubscriptionType.Monthly
|
||||
? module.monthlyPrice
|
||||
: module.basePrice;
|
||||
|
||||
const priceLabel =
|
||||
subscriptionType === SubscriptionType.Monthly ? "/mese" : "/anno";
|
||||
|
||||
// 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 (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: "auto" }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Indietro
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Attiva Modulo
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Scegli il piano di abbonamento per il modulo {module.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Alert dipendenze mancanti */}
|
||||
{missingDependencies.length > 0 && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }} icon={<WarningIcon />}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Questo modulo richiede i seguenti moduli che non sono attivi:
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||
{missingDependencies.map((dep) => (
|
||||
<Chip
|
||||
key={dep}
|
||||
label={dep}
|
||||
size="small"
|
||||
color="warning"
|
||||
onClick={() => navigate(`/modules/purchase/${dep}`)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Card principale */}
|
||||
<Card elevation={3}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
{/* Info modulo */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{module.name}
|
||||
</Typography>
|
||||
<Typography color="text.secondary" paragraph>
|
||||
{module.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Selezione tipo abbonamento */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom fontWeight="medium">
|
||||
Tipo di abbonamento
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={subscriptionType}
|
||||
exclusive
|
||||
onChange={(_, value) => value && setSubscriptionType(value)}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<ToggleButton value={SubscriptionType.Monthly}>
|
||||
<Box sx={{ py: 1, textAlign: "center" }}>
|
||||
<MonthlyIcon sx={{ mb: 0.5 }} />
|
||||
<Typography variant="body2" display="block">
|
||||
Mensile
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
{formatPrice(module.monthlyPrice)}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
/mese
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
</ToggleButton>
|
||||
<ToggleButton value={SubscriptionType.Annual}>
|
||||
<Box sx={{ py: 1, textAlign: "center" }}>
|
||||
<AnnualIcon sx={{ mb: 0.5 }} />
|
||||
<Typography variant="body2" display="block">
|
||||
Annuale
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
{formatPrice(module.basePrice)}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
/anno
|
||||
</Typography>
|
||||
</Typography>
|
||||
{savingsPercent > 0 && (
|
||||
<Chip
|
||||
label={`Risparmi ${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">
|
||||
Rinnovo automatico alla scadenza
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Riepilogo */}
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{ p: 2, mb: 3, bgcolor: "action.hover" }}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Riepilogo ordine
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
|
||||
>
|
||||
<Typography>Modulo {module.name}</Typography>
|
||||
<Typography>{formatPrice(price)}</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{ display: "flex", justifyContent: "space-between", mb: 1 }}
|
||||
>
|
||||
<Typography color="text.secondary">
|
||||
Abbonamento{" "}
|
||||
{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">Totale</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 ||
|
||||
"Errore durante l'attivazione del modulo"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Pulsante attivazione */}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
startIcon={
|
||||
enableMutation.isPending ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : (
|
||||
<CartIcon />
|
||||
)
|
||||
}
|
||||
onClick={() => enableMutation.mutate()}
|
||||
disabled={
|
||||
enableMutation.isPending || missingDependencies.length > 0
|
||||
}
|
||||
>
|
||||
{enableMutation.isPending
|
||||
? "Attivazione in corso..."
|
||||
: "Attiva Modulo"}
|
||||
</Button>
|
||||
|
||||
{/* Note */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
display="block"
|
||||
textAlign="center"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Potrai disattivare il modulo in qualsiasi momento dalle
|
||||
impostazioni. I dati inseriti rimarranno disponibili.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Funzionalità incluse */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Funzionalità incluse
|
||||
</Typography>
|
||||
<List dense>
|
||||
{getModuleFeatures(module.code).map((feature, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={feature} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper per ottenere le funzionalità di un modulo
|
||||
function getModuleFeatures(code: string): string[] {
|
||||
const features: Record<string, string[]> = {
|
||||
warehouse: [
|
||||
"Gestione anagrafica articoli",
|
||||
"Movimenti di magazzino (carico/scarico)",
|
||||
"Giacenze in tempo reale",
|
||||
"Valorizzazione scorte (FIFO, LIFO, medio ponderato)",
|
||||
"Inventario e rettifiche",
|
||||
"Report giacenze e movimenti",
|
||||
],
|
||||
purchases: [
|
||||
"Gestione ordini a fornitore",
|
||||
"DDT di entrata",
|
||||
"Fatture passive",
|
||||
"Scadenziario pagamenti",
|
||||
"Analisi acquisti per fornitore/articolo",
|
||||
"Storico prezzi di acquisto",
|
||||
],
|
||||
sales: [
|
||||
"Gestione ordini cliente",
|
||||
"DDT di uscita",
|
||||
"Fatturazione elettronica",
|
||||
"Scadenziario incassi",
|
||||
"Analisi vendite per cliente/articolo",
|
||||
"Listini prezzi",
|
||||
],
|
||||
production: [
|
||||
"Distinte base multilivello",
|
||||
"Cicli di lavoro",
|
||||
"Ordini di produzione",
|
||||
"Pianificazione MRP",
|
||||
"Avanzamento produzione",
|
||||
"Costi di produzione",
|
||||
],
|
||||
quality: [
|
||||
"Piani di controllo",
|
||||
"Registrazione controlli",
|
||||
"Gestione non conformità",
|
||||
"Azioni correttive/preventive",
|
||||
"Certificazioni e audit",
|
||||
"Statistiche qualità",
|
||||
],
|
||||
};
|
||||
|
||||
return features[code] || ["Funzionalità complete del modulo"];
|
||||
}
|
||||
515
frontend/src/pages/ModulesAdminPage.tsx
Normal file
515
frontend/src/pages/ModulesAdminPage.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
128
frontend/src/services/moduleService.ts
Normal file
128
frontend/src/services/moduleService.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import api from "./api";
|
||||
import type {
|
||||
ModuleDto,
|
||||
ModuleStatusDto,
|
||||
SubscriptionDto,
|
||||
EnableModuleRequest,
|
||||
UpdateSubscriptionRequest,
|
||||
RenewSubscriptionRequest,
|
||||
} from "../types/module";
|
||||
|
||||
/**
|
||||
* Service per la gestione dei moduli applicativi
|
||||
*/
|
||||
export const moduleService = {
|
||||
/**
|
||||
* Ottiene tutti i moduli disponibili con stato subscription
|
||||
*/
|
||||
getAll: async (): Promise<ModuleDto[]> => {
|
||||
const response = await api.get("/modules");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ottiene solo i moduli attivi (per costruzione menu)
|
||||
*/
|
||||
getActive: async (): Promise<ModuleDto[]> => {
|
||||
const response = await api.get("/modules/active");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ottiene un modulo specifico per codice
|
||||
*/
|
||||
getByCode: async (code: string): Promise<ModuleDto> => {
|
||||
const response = await api.get(`/modules/${code}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifica se un modulo è abilitato
|
||||
*/
|
||||
isEnabled: async (code: string): Promise<ModuleStatusDto> => {
|
||||
const response = await api.get(`/modules/${code}/enabled`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Attiva un modulo
|
||||
*/
|
||||
enable: async (code: string, request: EnableModuleRequest): Promise<SubscriptionDto> => {
|
||||
const response = await api.put(`/modules/${code}/enable`, request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disattiva un modulo
|
||||
*/
|
||||
disable: async (code: string): Promise<{ message: string }> => {
|
||||
const response = await api.put(`/modules/${code}/disable`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ottiene tutte le subscription
|
||||
*/
|
||||
getAllSubscriptions: async (): Promise<SubscriptionDto[]> => {
|
||||
const response = await api.get("/modules/subscriptions");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Aggiorna la subscription di un modulo
|
||||
*/
|
||||
updateSubscription: async (
|
||||
code: string,
|
||||
request: UpdateSubscriptionRequest
|
||||
): Promise<SubscriptionDto> => {
|
||||
const response = await api.put(`/modules/${code}/subscription`, request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rinnova la subscription di un modulo
|
||||
*/
|
||||
renewSubscription: async (
|
||||
code: string,
|
||||
request?: RenewSubscriptionRequest
|
||||
): Promise<SubscriptionDto> => {
|
||||
const response = await api.post(`/modules/${code}/subscription/renew`, request || {});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ottiene i moduli in scadenza
|
||||
*/
|
||||
getExpiring: async (daysThreshold: number = 30): Promise<ModuleDto[]> => {
|
||||
const response = await api.get("/modules/expiring", {
|
||||
params: { daysThreshold },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Inizializza i moduli di default (admin)
|
||||
*/
|
||||
seedDefault: async (): Promise<{ message: string }> => {
|
||||
const response = await api.post("/modules/seed");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Forza il controllo delle subscription scadute (admin)
|
||||
*/
|
||||
checkExpired: async (): Promise<{ message: string }> => {
|
||||
const response = await api.post("/modules/check-expired");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalida la cache dei moduli (admin)
|
||||
*/
|
||||
invalidateCache: async (): Promise<{ message: string }> => {
|
||||
const response = await api.post("/modules/invalidate-cache");
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default moduleService;
|
||||
174
frontend/src/types/module.ts
Normal file
174
frontend/src/types/module.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Tipi di subscription per i moduli
|
||||
*/
|
||||
export enum SubscriptionType {
|
||||
None = 0,
|
||||
Monthly = 1,
|
||||
Annual = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Informazioni sulla subscription di un modulo
|
||||
*/
|
||||
export interface SubscriptionDto {
|
||||
id: number;
|
||||
moduleId: number;
|
||||
moduleCode?: string;
|
||||
moduleName?: string;
|
||||
isEnabled: boolean;
|
||||
subscriptionType: SubscriptionType;
|
||||
subscriptionTypeName: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
autoRenew: boolean;
|
||||
notes?: string;
|
||||
lastRenewalDate?: string;
|
||||
paidPrice?: number;
|
||||
isValid: boolean;
|
||||
daysRemaining?: number;
|
||||
isExpiringSoon: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rappresenta un modulo dell'applicazione
|
||||
*/
|
||||
export interface ModuleDto {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
basePrice: number;
|
||||
monthlyPrice: number;
|
||||
monthlyMultiplier: number;
|
||||
sortOrder: number;
|
||||
isCore: boolean;
|
||||
dependencies: string[];
|
||||
routePath?: string;
|
||||
isAvailable: boolean;
|
||||
isEnabled: boolean;
|
||||
subscription?: SubscriptionDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stato di un modulo (risposta da /api/modules/{code}/enabled)
|
||||
*/
|
||||
export interface ModuleStatusDto {
|
||||
code: string;
|
||||
isEnabled: boolean;
|
||||
hasValidSubscription: boolean;
|
||||
isCore: boolean;
|
||||
daysRemaining?: number;
|
||||
isExpiringSoon: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request per attivare un modulo
|
||||
*/
|
||||
export interface EnableModuleRequest {
|
||||
subscriptionType?: SubscriptionType;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
autoRenew?: boolean;
|
||||
paidPrice?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request per aggiornare una subscription
|
||||
*/
|
||||
export interface UpdateSubscriptionRequest {
|
||||
subscriptionType?: SubscriptionType;
|
||||
endDate?: string;
|
||||
autoRenew?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request per rinnovare una subscription
|
||||
*/
|
||||
export interface RenewSubscriptionRequest {
|
||||
paidPrice?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper per ottenere il nome visualizzato del tipo subscription
|
||||
*/
|
||||
export function getSubscriptionTypeName(type: SubscriptionType): string {
|
||||
switch (type) {
|
||||
case SubscriptionType.None:
|
||||
return "Nessuno";
|
||||
case SubscriptionType.Monthly:
|
||||
return "Mensile";
|
||||
case SubscriptionType.Annual:
|
||||
return "Annuale";
|
||||
default:
|
||||
return "Sconosciuto";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper per formattare il prezzo
|
||||
*/
|
||||
export function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper per formattare la data
|
||||
*/
|
||||
export function formatDate(dateString?: string): string {
|
||||
if (!dateString) return "-";
|
||||
return new Date(dateString).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper per calcolare i giorni rimanenti
|
||||
*/
|
||||
export function getDaysRemainingText(days?: number): string {
|
||||
if (days === undefined || days === null) return "Nessuna scadenza";
|
||||
if (days === 0) return "Scaduto";
|
||||
if (days === 1) return "1 giorno";
|
||||
return `${days} giorni`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper per ottenere il colore dello stato subscription
|
||||
*/
|
||||
export function getSubscriptionStatusColor(
|
||||
subscription?: SubscriptionDto
|
||||
): "success" | "warning" | "error" | "default" {
|
||||
if (!subscription || !subscription.isEnabled) return "default";
|
||||
if (!subscription.isValid) return "error";
|
||||
if (subscription.isExpiringSoon) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper per ottenere il testo dello stato subscription
|
||||
*/
|
||||
export function getSubscriptionStatusText(subscription?: SubscriptionDto): string {
|
||||
if (!subscription) return "Non attivo";
|
||||
if (!subscription.isEnabled) return "Disattivato";
|
||||
if (!subscription.isValid) return "Scaduto";
|
||||
if (subscription.isExpiringSoon) return "In scadenza";
|
||||
return "Attivo";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappa delle icone MUI per i moduli
|
||||
*/
|
||||
export const moduleIcons: Record<string, string> = {
|
||||
warehouse: "Warehouse",
|
||||
purchases: "ShoppingCart",
|
||||
sales: "PointOfSale",
|
||||
production: "PrecisionManufacturing",
|
||||
quality: "VerifiedUser",
|
||||
};
|
||||
Reference in New Issue
Block a user