Files
formation-manager-itinova/client/src/pages/admin/AdminRappelsHistorique.tsx
Manus Sandbox 355cb43dc7 Checkpoint: Historique, statistiques et réessai automatique des rappels
**Nouvelles fonctionnalités** :

1. **Page d'historique des rappels** (`/admin/rappels/historique`)
   - Tableau complet de tous les rappels envoyés
   - Filtres avancés : date début/fin, séquence, statut (succès/échec), email
   - Recherche par email avec mise en surbrillance
   - Pagination (50 résultats par page)
   - Affichage du nombre de tentatives pour chaque rappel
   - Code couleur : vert pour succès, rouge pour échec

2. **Tableau de bord statistiques** (`/admin/rappels/statistiques`)
   - 4 cartes de statistiques globales : total envoyés, succès, échecs, taux de succès
   - Graphique d'évolution : nombre de rappels envoyés par jour (succès vs échecs)
   - Graphique du taux de succès par jour (barres)
   - Top 10 des emails problématiques avec nombre d'échecs
   - Visualisation interactive avec Recharts

3. **Système de réessai automatique**
   - Détection automatique des rappels échoués
   - Délai exponentiel : 1h après 1er échec, 4h après 2ème, 24h après 3ème
   - Limite de 3 tentatives maximum
   - Notification automatique à l'admin en cas d'abandon (3 échecs)
   - Mise à jour automatique du statut dans les logs
   - Vérification toutes les heures

**Nouveaux champs dans logsRappels** :
- `nbTentatives` : nombre de tentatives d'envoi (1 à 3)
- `prochainEssai` : date du prochain essai en cas d'échec

**Nouveaux fichiers** :
- `client/src/pages/admin/AdminRappelsHistorique.tsx` : Page d'historique
- `client/src/pages/admin/AdminRappelsStats.tsx` : Page de statistiques
- `server/rappelRetry.ts` : Système de réessai automatique
- `server/rappelDb.ts` : Fonctions étendues (filtres, stats, évolution)

**Fichiers modifiés** :
- `drizzle/schema.ts` : Ajout de nbTentatives et prochainEssai
- `server/routers.ts` : Nouvelles procédures tRPC (historique, statistiques, évolution)
- `client/src/App.tsx` : Routes pour historique et statistiques
- `client/src/components/DashboardLayout.tsx` : Sous-menu Rappels
- `server/_core/index.ts` : Initialisation du scheduler de retry

**Bénéfices** :
-  Traçabilité complète de tous les rappels
-  Analyse des performances avec graphiques
-  Identification rapide des emails problématiques
-  Réessai automatique des échecs sans intervention manuelle
-  Notification en cas d'échec persistant
-  Optimisation de la délivrabilité des emails
2025-12-08 03:38:14 -05:00

263 lines
9.9 KiB
TypeScript

import { useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { History, Search, Filter, ChevronLeft, ChevronRight } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
export default function AdminRappelsHistorique() {
const [filtres, setFiltres] = useState({
dateDebut: "",
dateFin: "",
sequenceId: undefined as number | undefined,
statut: undefined as "success" | "failed" | undefined,
email: "",
limit: 50,
offset: 0,
});
const { data: logs, isLoading } = trpc.rappels.historique.useQuery(filtres);
const { data: sequences } = trpc.sequences.list.useQuery();
const handleFilterChange = (key: string, value: any) => {
setFiltres(prev => ({
...prev,
[key]: value,
offset: 0, // Reset pagination
}));
};
const handlePageChange = (direction: "prev" | "next") => {
setFiltres(prev => ({
...prev,
offset: direction === "next" ? prev.offset + prev.limit : Math.max(0, prev.offset - prev.limit),
}));
};
const resetFilters = () => {
setFiltres({
dateDebut: "",
dateFin: "",
sequenceId: undefined,
statut: undefined,
email: "",
limit: 50,
offset: 0,
});
};
return (
<DashboardLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Historique des Rappels</h1>
<p className="text-muted-foreground mt-1">
Consultez tous les rappels envoyés avec filtres avancés
</p>
</div>
<History className="h-8 w-8 text-muted-foreground" />
</div>
{/* Filtres */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
Filtres
</CardTitle>
<CardDescription>Affinez votre recherche dans l'historique</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="dateDebut">Date début</Label>
<Input
id="dateDebut"
type="date"
value={filtres.dateDebut}
onChange={(e) => handleFilterChange("dateDebut", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dateFin">Date fin</Label>
<Input
id="dateFin"
type="date"
value={filtres.dateFin}
onChange={(e) => handleFilterChange("dateFin", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sequence">Séquence</Label>
<Select
value={filtres.sequenceId?.toString() || "all"}
onValueChange={(value) => handleFilterChange("sequenceId", value === "all" ? undefined : parseInt(value))}
>
<SelectTrigger id="sequence">
<SelectValue placeholder="Toutes les séquences" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les séquences</SelectItem>
{sequences?.map((seq) => (
<SelectItem key={seq.id} value={seq.id.toString()}>
{seq.nom}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="statut">Statut</Label>
<Select
value={filtres.statut || "all"}
onValueChange={(value) => handleFilterChange("statut", value === "all" ? undefined : value)}
>
<SelectTrigger id="statut">
<SelectValue placeholder="Tous les statuts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="success">Succès</SelectItem>
<SelectItem value="failed">Échec</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="email">Rechercher par email</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="text"
placeholder="email@exemple.com"
className="pl-10"
value={filtres.email}
onChange={(e) => handleFilterChange("email", e.target.value)}
/>
</div>
</div>
<div className="flex items-end gap-2 md:col-span-2">
<Button variant="outline" onClick={resetFilters} className="w-full">
Réinitialiser
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Tableau des logs */}
<Card>
<CardHeader>
<CardTitle>Logs des rappels</CardTitle>
<CardDescription>
{logs?.length || 0} résultat(s) trouvé(s)
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
Chargement...
</div>
) : !logs || logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucun log trouvé avec ces filtres
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date envoi</TableHead>
<TableHead>Email</TableHead>
<TableHead>Type</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Tentatives</TableHead>
<TableHead>Erreur</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap">
{format(new Date(log.dateEnvoi), "dd/MM/yyyy HH:mm", { locale: fr })}
</TableCell>
<TableCell className="font-medium">{log.emailDestinataire}</TableCell>
<TableCell>
<Badge variant="outline">
{log.typeRappel === "rappel" ? "J-7" : "J-1"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={log.statut === "success" ? "default" : "destructive"}>
{log.statut === "success" ? "Succès" : "Échec"}
</Badge>
</TableCell>
<TableCell>
<span className={log.nbTentatives > 1 ? "text-orange-600 font-medium" : ""}>
{log.nbTentatives}
</span>
</TableCell>
<TableCell className="max-w-xs truncate text-sm text-muted-foreground">
{log.messageErreur || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Affichage de {filtres.offset + 1} à {Math.min(filtres.offset + filtres.limit, filtres.offset + (logs?.length || 0))}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange("prev")}
disabled={filtres.offset === 0}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange("next")}
disabled={!logs || logs.length < filtres.limit}
>
Suivant
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}