Files
formation-manager-itinova/client/src/pages/AdminParametres.tsx
Manus fc4df8ed36 Checkpoint: Correction des erreurs sur la page rapport-public-cible et harmonisation de la charte graphique des titres :
**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
2026-01-20 11:46:56 -05:00

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>
);
}