Rollback to 9d67724e
This commit is contained in:
557
client/src/pages/AdminSequenceInscrits.tsx
Normal file
557
client/src/pages/AdminSequenceInscrits.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { ArrowLeft, Download, Mail, Search } from "lucide-react";
|
||||
import { useLocation, useRoute } from "wouter";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminSequenceInscrits() {
|
||||
const [, setLocation] = useLocation();
|
||||
const [, params] = useRoute("/admin/sequences/:id/inscrits");
|
||||
const sequenceId = params?.id ? parseInt(params.id) : 0;
|
||||
|
||||
const getPublicCibleBadge = (publicCible: string) => {
|
||||
const colors = {
|
||||
directeur: "bg-blue-100 text-blue-800",
|
||||
chef_service: "bg-green-100 text-green-800",
|
||||
autre: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return colors[publicCible as keyof typeof colors] || colors.autre;
|
||||
};
|
||||
|
||||
const getPublicCibleLabel = (publicCible: string) => {
|
||||
const labels = {
|
||||
directeur: "Directeur",
|
||||
chef_service: "Chef de service",
|
||||
autre: "Autre",
|
||||
};
|
||||
return labels[publicCible as keyof typeof labels] || "Autre";
|
||||
};
|
||||
|
||||
// États pour les filtres
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterStatut, setFilterStatut] = useState<string>("all");
|
||||
const [filterEtablissement, setFilterEtablissement] = useState<string>("all");
|
||||
const [filterFonction, setFilterFonction] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<"date" | "nom">("date");
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId });
|
||||
const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySequence.useQuery({ sequenceId });
|
||||
const { data: formations } = trpc.formations.list.useQuery();
|
||||
|
||||
const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.inscriptions.listBySequence.invalidate({ sequenceId });
|
||||
toast.success("Statut mis à jour avec succès");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de la mise à jour : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const sendEmailsMutation = trpc.inscriptions.sendGroupEmail.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Emails envoyés : ${result.sent} réussis, ${result.failed} échecs`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'envoi : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleStatutChange = (inscriptionId: number, newStatut: string) => {
|
||||
updateStatutMutation.mutate({
|
||||
id: inscriptionId,
|
||||
statut: newStatut as "confirmee" | "liste_attente" | "annulee",
|
||||
});
|
||||
};
|
||||
|
||||
const exportExcelMutation = trpc.inscriptions.exportExcel.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.buffer), c => c.charCodeAt(0))], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Export Excel téléchargé");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const exportPDFMutation = trpc.inscriptions.exportPDF.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.buffer), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Export PDF téléchargé");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const exportFeuilleMutation = trpc.inscriptions.exportFeuillePresence.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const blob = new Blob([Uint8Array.from(atob(result.buffer), c => c.charCodeAt(0))], {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Feuille de présence téléchargée");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Erreur lors de l'export : " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleExportExcel = () => {
|
||||
exportExcelMutation.mutate({ sequenceId });
|
||||
};
|
||||
|
||||
const handleExportPDF = () => {
|
||||
exportPDFMutation.mutate({ sequenceId });
|
||||
};
|
||||
|
||||
const handleExportFeuille = () => {
|
||||
exportFeuilleMutation.mutate({ sequenceId });
|
||||
};
|
||||
|
||||
const handleSendEmails = (type: "teaser" | "rappel") => {
|
||||
if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${type === "teaser" ? "teaser" : "de rappel J-7"} à tous les inscrits confirmés ?`)) {
|
||||
sendEmailsMutation.mutate({ sequenceId, type });
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrage et tri
|
||||
const filteredAndSortedInscriptions = useMemo(() => {
|
||||
if (!inscriptions) return [];
|
||||
|
||||
let filtered = inscriptions.filter((item) => {
|
||||
if (!item.apprenant) return false;
|
||||
|
||||
const matchSearch =
|
||||
item.apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.apprenant.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchStatut = filterStatut === "all" || item.inscription.statut === filterStatut;
|
||||
const matchEtablissement = filterEtablissement === "all" || item.apprenant.codeEtablissement === filterEtablissement;
|
||||
const matchFonction = filterFonction === "all" || item.apprenant.fonction === filterFonction;
|
||||
|
||||
return matchSearch && matchStatut && matchEtablissement && matchFonction;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === "date") {
|
||||
return new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime();
|
||||
} else {
|
||||
return (a.apprenant?.nom || "").localeCompare(b.apprenant?.nom || "");
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [inscriptions, searchTerm, filterStatut, filterEtablissement, filterFonction, sortBy]);
|
||||
|
||||
const uniqueEtablissements = useMemo(() => {
|
||||
if (!inscriptions) return [];
|
||||
return Array.from(new Set(inscriptions.map((i) => i.apprenant?.codeEtablissement).filter(Boolean)));
|
||||
}, [inscriptions]);
|
||||
|
||||
const getFormationName = () => {
|
||||
if (!sequence || !formations) return "N/A";
|
||||
return formations.find((f) => f.id === sequence.formationId)?.nom || "N/A";
|
||||
};
|
||||
|
||||
const formatDate = (dateValue: any) => {
|
||||
if (!dateValue) return "N/A";
|
||||
|
||||
try {
|
||||
// Si c'est déjà un objet Date JavaScript
|
||||
if (dateValue instanceof Date) {
|
||||
const day = String(dateValue.getDate()).padStart(2, '0');
|
||||
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
|
||||
const year = dateValue.getFullYear();
|
||||
const hours = String(dateValue.getHours()).padStart(2, '0');
|
||||
const minutes = String(dateValue.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Parser la date MySQL (chaîne)
|
||||
const dateStr = String(dateValue);
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})/);
|
||||
|
||||
if (!match) {
|
||||
console.warn('Format de date non reconnu:', dateValue);
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const [, year, month, day, hours, minutes] = match;
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
} catch (error) {
|
||||
console.error('Erreur de formatage de date:', error, dateValue);
|
||||
return "N/A";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatutBadge = (statut: string) => {
|
||||
const colors = {
|
||||
confirmee: "bg-green-100 text-green-800",
|
||||
liste_attente: "bg-orange-100 text-orange-800",
|
||||
annulee: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
const labels = {
|
||||
confirmee: "Confirmée",
|
||||
liste_attente: "Liste d'attente",
|
||||
annulee: "Annulée",
|
||||
};
|
||||
return {
|
||||
color: colors[statut as keyof typeof colors] || colors.confirmee,
|
||||
label: labels[statut as keyof typeof labels] || statut,
|
||||
};
|
||||
};
|
||||
|
||||
const getFonctionLabel = (fonction: string) => {
|
||||
const labels = {
|
||||
directeur: "Directeur",
|
||||
chef_service: "Chef de service",
|
||||
autre: "Autre",
|
||||
};
|
||||
return labels[fonction as keyof typeof labels] || fonction;
|
||||
};
|
||||
|
||||
if (loadingSequence || loadingInscriptions) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sequence) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Séquence introuvable</CardTitle>
|
||||
<CardDescription>La séquence demandée n'existe pas.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setLocation("/admin/sequences")}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Retour aux séquences
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const nbConfirmes = inscriptions?.filter((i) => i.inscription.statut === "confirmee").length || 0;
|
||||
const nbListeAttente = inscriptions?.filter((i) => i.inscription.statut === "liste_attente").length || 0;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Button variant="ghost" onClick={() => setLocation("/admin/sequences")} className="mb-2">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Retour aux séquences
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">Inscrits à la séquence</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{getFormationName()} - {sequence.nom}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informations de la séquence */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informations de la séquence</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Formation</p>
|
||||
<p className="text-base">{getFormationName()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Nom de la séquence</p>
|
||||
<p className="text-base">{sequence.nom}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Lieu</p>
|
||||
<p className="text-base">{sequence.lieu}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Capacité</p>
|
||||
<p className="text-base">
|
||||
{nbConfirmes} / {sequence.capaciteMax} inscrits confirmés
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Public cible</p>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(sequence.publicCible)}`}>
|
||||
{getPublicCibleLabel(sequence.publicCible)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Dates de formation</p>
|
||||
<div className="space-y-2">
|
||||
{sequence.dates.map((date: any) => (
|
||||
<div key={date.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">Date {date.ordre}:</span>
|
||||
<span>
|
||||
Du {formatDate(date.dateDebut)} au {formatDate(date.dateFin)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actions</CardTitle>
|
||||
<CardDescription>Envoi d'emails et exports</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSendEmails("teaser")}
|
||||
disabled={sendEmailsMutation.isPending || nbConfirmes === 0}
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Envoyer email teaser
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleSendEmails("rappel")}
|
||||
disabled={sendEmailsMutation.isPending || nbConfirmes === 0}
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Envoyer rappel J-7
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportExcel}
|
||||
disabled={exportExcelMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportPDF}
|
||||
disabled={exportPDFMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportFeuille}
|
||||
disabled={exportFeuilleMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Feuille de présence
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filtres */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
Filtres et recherche
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Recherche</Label>
|
||||
<Input
|
||||
placeholder="Nom, prénom, email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Statut</Label>
|
||||
<Select value={filterStatut} onValueChange={setFilterStatut}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous</SelectItem>
|
||||
<SelectItem value="confirmee">Confirmée</SelectItem>
|
||||
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
|
||||
<SelectItem value="annulee">Annulée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Établissement</Label>
|
||||
<Select value={filterEtablissement} onValueChange={setFilterEtablissement}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous</SelectItem>
|
||||
{uniqueEtablissements.map((etab) => (
|
||||
<SelectItem key={etab} value={etab as string}>
|
||||
{etab}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Fonction</Label>
|
||||
<Select value={filterFonction} onValueChange={setFilterFonction}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes</SelectItem>
|
||||
<SelectItem value="directeur">Directeur</SelectItem>
|
||||
<SelectItem value="chef_service">Chef de service</SelectItem>
|
||||
<SelectItem value="autre">Autre</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Trier par</Label>
|
||||
<Select value={sortBy} onValueChange={(v: any) => setSortBy(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date">Date d'inscription</SelectItem>
|
||||
<SelectItem value="nom">Nom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
{filteredAndSortedInscriptions.length} inscription(s) trouvée(s) sur {inscriptions?.length || 0} au total
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Liste des inscrits */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des inscrits</CardTitle>
|
||||
<CardDescription>
|
||||
{nbConfirmes} confirmé(s), {nbListeAttente} en liste d'attente
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredAndSortedInscriptions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucune inscription trouvée
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Prénom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Code étab.</TableHead>
|
||||
<TableHead>Fonction</TableHead>
|
||||
<TableHead>Date inscription</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedInscriptions.map((item) => {
|
||||
if (!item.apprenant) return null;
|
||||
const statutInfo = getStatutBadge(item.inscription.statut);
|
||||
|
||||
return (
|
||||
<TableRow key={item.inscription.id}>
|
||||
<TableCell className="font-medium">{item.apprenant.nom}</TableCell>
|
||||
<TableCell>{item.apprenant.prenom}</TableCell>
|
||||
<TableCell>{item.apprenant.email}</TableCell>
|
||||
<TableCell>{item.apprenant.codeEtablissement}</TableCell>
|
||||
<TableCell>{getFonctionLabel(item.apprenant.fonction)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDate(item.inscription.dateInscription)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statutInfo.color}`}>
|
||||
{statutInfo.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Select
|
||||
value={item.inscription.statut}
|
||||
onValueChange={(value) => handleStatutChange(item.inscription.id, value)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="confirmee">Confirmée</SelectItem>
|
||||
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
|
||||
<SelectItem value="annulee">Annulée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user