**Corrections d'erreurs :** 1. **Erreurs SQL DATE_FORMAT corrigées** (analyticsDb.ts) : - Remplacement des sous-requêtes corrélées par des JOIN dans getTauxRemplissageByMonth() - Utilisation de COUNT(CASE WHEN...) au lieu de SUM(SELECT COUNT(*)) - Ajout de leftJoin pour éviter les erreurs de sous-requêtes 2. **Erreur React "Cannot read properties of null" corrigée** (AdminRapportPublicCible.tsx) : - Ajout de l'opérateur null-safe `?.` pour accéder à `insc.apprenant?.fonction` - Vérification explicite si la fonction est null/undefined - Gestion du cas "public cible = tous" qui correspond toujours 3. **Tests réussis** : - Page rapport-public-cible : affiche correctement les statistiques et recommandations - Tableau de bord analytique : graphiques et statistiques fonctionnels - Page suivi questionnaires : affiche correctement les données **Harmonisation charte graphique :** - Application du style bleu clair (#6B9FE8) avec ombre portée à tous les titres des 32 pages - Création de la classe CSS `.page-title` réutilisable dans index.css - Cohérence visuelle sur toute l'application **Résultat :** ✅ Toutes les erreurs SQL et React sont corrigées ✅ Les pages fonctionnent correctement sans erreur ✅ Charte graphique harmonisée sur toutes les pages
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { toast } from "sonner";
|
|
import { Loader2, Save, Settings, CheckCircle2, XCircle, History } from "lucide-react";
|
|
import DashboardLayout from "@/components/DashboardLayout";
|
|
|
|
export default function AdminParametres() {
|
|
const { data: parametres, isLoading, refetch } = trpc.parametres.get.useQuery();
|
|
const { data: historique, isLoading: isLoadingHistorique } = trpc.parametres.getHistorique.useQuery({ limit: 20 });
|
|
|
|
const [urlPublique, setUrlPublique] = useState("");
|
|
const [delaiExpirationQR, setDelaiExpirationQR] = useState(30);
|
|
const [dureeValiditeToken, setDureeValiditeToken] = useState(24);
|
|
const [notificationsActives, setNotificationsActives] = useState(true);
|
|
|
|
const [testUrlStatus, setTestUrlStatus] = useState<{ loading: boolean; result?: { accessible: boolean; message: string } }>({ loading: false });
|
|
|
|
// Initialiser les valeurs quand les données sont chargées
|
|
useEffect(() => {
|
|
if (parametres) {
|
|
setUrlPublique(parametres.urlPublique);
|
|
setDelaiExpirationQR(parametres.delaiExpirationQR);
|
|
setDureeValiditeToken(parametres.dureeValiditeToken);
|
|
setNotificationsActives(parametres.notificationsActives);
|
|
|
|
}
|
|
}, [parametres]);
|
|
|
|
const updateMutation = trpc.parametres.update.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Paramètres mis à jour avec succès");
|
|
refetch();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Erreur : ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const testUrlMutation = trpc.parametres.testerUrl.useMutation({
|
|
onSuccess: (result) => {
|
|
setTestUrlStatus({ loading: false, result });
|
|
if (result.accessible) {
|
|
toast.success(result.message);
|
|
} else {
|
|
toast.error(result.message);
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
setTestUrlStatus({ loading: false });
|
|
toast.error(`Erreur lors du test : ${error.message}`);
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
// Validation de l'URL
|
|
try {
|
|
new URL(urlPublique);
|
|
} catch {
|
|
toast.error("L'URL saisie n'est pas valide");
|
|
return;
|
|
}
|
|
|
|
updateMutation.mutate({
|
|
urlPublique,
|
|
delaiExpirationQR,
|
|
dureeValiditeToken,
|
|
notificationsActives,
|
|
|
|
});
|
|
};
|
|
|
|
const handleTestUrl = () => {
|
|
try {
|
|
new URL(urlPublique);
|
|
setTestUrlStatus({ loading: true });
|
|
testUrlMutation.mutate({ url: urlPublique });
|
|
} catch {
|
|
toast.error("L'URL saisie n'est pas valide");
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="container max-w-6xl py-8">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Settings className="h-8 w-8 text-primary" />
|
|
<div>
|
|
<h1 className="page-title">Paramètres de l'application</h1>
|
|
<p className="text-muted-foreground">
|
|
Configuration globale de l'application
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6">
|
|
{/* Configuration générale */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Configuration générale</CardTitle>
|
|
<CardDescription>
|
|
Paramètres globaux de l'application
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* URL publique */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="urlPublique">URL publique de l'application</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="urlPublique"
|
|
type="url"
|
|
placeholder="https://formations.itinova.org"
|
|
value={urlPublique}
|
|
onChange={(e) => {
|
|
setUrlPublique(e.target.value);
|
|
setTestUrlStatus({ loading: false });
|
|
}}
|
|
required
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleTestUrl}
|
|
disabled={testUrlStatus.loading}
|
|
className="gap-2"
|
|
>
|
|
{testUrlStatus.loading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Test...
|
|
</>
|
|
) : testUrlStatus.result ? (
|
|
testUrlStatus.result.accessible ? (
|
|
<>
|
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
Accessible
|
|
</>
|
|
) : (
|
|
<>
|
|
<XCircle className="h-4 w-4 text-red-600" />
|
|
Erreur
|
|
</>
|
|
)
|
|
) : (
|
|
"Tester l'URL"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Cette URL est utilisée pour générer les QR codes d'émargement.
|
|
Exemple : https://formations.itinova.org (sans slash à la fin)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Délai d'expiration QR codes */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="delaiExpirationQR">Délai d'expiration des QR codes (minutes)</Label>
|
|
<Input
|
|
id="delaiExpirationQR"
|
|
type="number"
|
|
min="5"
|
|
max="120"
|
|
value={delaiExpirationQR}
|
|
onChange={(e) => setDelaiExpirationQR(parseInt(e.target.value))}
|
|
required
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
Durée de validité des QR codes d'émargement après leur génération (entre 5 et 120 minutes)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Durée de validité des tokens */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="dureeValiditeToken">Durée de validité des tokens d'émargement (heures)</Label>
|
|
<Input
|
|
id="dureeValiditeToken"
|
|
type="number"
|
|
min="1"
|
|
max="72"
|
|
value={dureeValiditeToken}
|
|
onChange={(e) => setDureeValiditeToken(parseInt(e.target.value))}
|
|
required
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
Durée pendant laquelle les tokens d'émargement restent valides (entre 1 et 72 heures)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Notifications actives */}
|
|
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="notificationsActives" className="text-base">
|
|
Notifications automatiques
|
|
</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Activer ou désactiver l'envoi automatique de notifications
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="notificationsActives"
|
|
checked={notificationsActives}
|
|
onCheckedChange={setNotificationsActives}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 pt-4">
|
|
<Button
|
|
type="submit"
|
|
disabled={updateMutation.isPending}
|
|
className="gap-2"
|
|
>
|
|
{updateMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Enregistrement...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="h-4 w-4" />
|
|
Enregistrer
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Historique des modifications */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<History className="h-5 w-5 text-primary" />
|
|
<CardTitle>Historique des modifications</CardTitle>
|
|
</div>
|
|
<CardDescription>
|
|
Traçabilité des changements de paramètres
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoadingHistorique ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : !historique || historique.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
Aucune modification enregistrée
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Date</TableHead>
|
|
<TableHead>Utilisateur</TableHead>
|
|
<TableHead>Champ modifié</TableHead>
|
|
<TableHead>Ancienne valeur</TableHead>
|
|
<TableHead>Nouvelle valeur</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{historique.map((entry) => (
|
|
<TableRow key={entry.id}>
|
|
<TableCell className="font-mono text-sm">
|
|
{new Date(entry.dateModification).toLocaleString('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</TableCell>
|
|
<TableCell>{entry.userName}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">
|
|
{entry.champModifie}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{entry.ancienneValeur || <span className="italic">vide</span>}
|
|
</TableCell>
|
|
<TableCell className="font-medium">
|
|
{entry.nouvelleValeur || <span className="italic">vide</span>}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|