Checkpoint: Amélioration de la page Paramètres avec :
- Ajout de 3 nouveaux paramètres globaux : délai d'expiration QR codes (minutes), durée de validité tokens (heures), notifications actives (boolean) - Création de la table historiqueParametres pour tracer toutes les modifications avec userId, userName, champModifié, ancienneValeur, nouvelleValeur et dateModification - Ajout d'un bouton "Tester l'URL" avec indicateur visuel de statut (vert si accessible, rouge si erreur) - Affichage de l'historique des modifications dans un tableau avec toutes les informations de traçabilité - Mise à jour automatique de l'historique lors de chaque modification de paramètre
This commit is contained in:
@@ -4,22 +4,34 @@ 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 } from "lucide-react";
|
||||
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 [urlPublique, setUrlPublique] = useState("");
|
||||
const { data: historique, isLoading: isLoadingHistorique } = trpc.parametres.getHistorique.useQuery({ limit: 20 });
|
||||
|
||||
// Initialiser l'URL quand les données sont chargées
|
||||
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.updateUrlPublique.useMutation({
|
||||
const updateMutation = trpc.parametres.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Paramètres mis à jour avec succès");
|
||||
refetch();
|
||||
@@ -29,6 +41,21 @@ export default function AdminParametres() {
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -40,86 +67,248 @@ export default function AdminParametres() {
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate({ urlPublique });
|
||||
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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<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-4xl py-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Settings className="h-8 w-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Paramètres de l'application</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configuration globale de l'application
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>URL publique de l'application</CardTitle>
|
||||
<CardDescription>
|
||||
Cette URL est utilisée pour générer les QR codes d'émargement.
|
||||
Elle doit correspondre à l'URL publique de votre serveur.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="urlPublique">URL publique</Label>
|
||||
<Input
|
||||
id="urlPublique"
|
||||
type="url"
|
||||
placeholder="https://formations.itinova.org"
|
||||
value={urlPublique}
|
||||
onChange={(e) => setUrlPublique(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Exemple : https://formations.itinova.org (sans slash à la fin)
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||
<h3 className="font-semibold mb-2">💡 Information</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Après avoir modifié l'URL publique, les nouveaux QR codes générés
|
||||
utiliseront automatiquement cette URL. Les QR codes déjà générés
|
||||
resteront inchangés.
|
||||
<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="text-3xl font-bold">Paramètres de l'application</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configuration globale de l'application
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user