927 lines
40 KiB
TypeScript
927 lines
40 KiB
TypeScript
import { useState, useMemo, useEffect } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { trpc } from "@/lib/trpc";
|
|
import DashboardLayout from "@/components/DashboardLayout";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { toast } from "sonner";
|
|
import { Eye, Send, Upload, Trash2, FileText, Calendar, Filter } from "lucide-react";
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
|
|
// Composant de dialogue d'upload d'attestation
|
|
function UploadAttestationDialog({
|
|
open,
|
|
onOpenChange,
|
|
inscriptionId,
|
|
onSuccess,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
inscriptionId: number | null;
|
|
onSuccess: () => void;
|
|
}) {
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState(0);
|
|
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
|
const [fileData, setFileData] = useState<{
|
|
nomFichier: string;
|
|
urlFichier: string;
|
|
s3Key: string;
|
|
typeFichier: string;
|
|
tailleFichier: number;
|
|
} | null>(null);
|
|
|
|
// Réinitialiser l'état quand la modal s'ouvre
|
|
useEffect(() => {
|
|
if (open) {
|
|
setUploading(false);
|
|
setUploadProgress(0);
|
|
setSelectedFileName(null);
|
|
setFileData(null);
|
|
}
|
|
}, [open]);
|
|
|
|
const uploadMutation = trpc.gestionAttestations.uploadDocument.useMutation({
|
|
onSuccess: () => {
|
|
onSuccess();
|
|
setFileData(null);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Erreur lors de l'upload : ${error.message}`);
|
|
setUploading(false);
|
|
},
|
|
});
|
|
|
|
const handleUpload = () => {
|
|
if (!fileData || !inscriptionId) return;
|
|
|
|
setUploading(true);
|
|
uploadMutation.mutate({
|
|
inscriptionId,
|
|
documentUrl: fileData.urlFichier,
|
|
documentS3Key: fileData.s3Key,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader className="space-y-3">
|
|
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg">
|
|
<Upload className="w-8 h-8 text-white" />
|
|
</div>
|
|
<DialogTitle className="text-2xl font-bold text-center bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
|
Charger une attestation
|
|
</DialogTitle>
|
|
<DialogDescription className="text-center text-base">
|
|
Sélectionnez un fichier PDF pour cet apprenant
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
<div className="relative">
|
|
<label className="block">
|
|
<div className={`
|
|
relative border-3 border-dashed rounded-2xl p-8 text-center cursor-pointer
|
|
transition-all duration-300 ease-in-out
|
|
${
|
|
uploading
|
|
? 'border-blue-400 bg-blue-50'
|
|
: fileData
|
|
? 'border-green-400 bg-green-50 hover:bg-green-100'
|
|
: 'border-gray-300 bg-gray-50 hover:border-blue-400 hover:bg-blue-50'
|
|
}
|
|
`}>
|
|
{!fileData && !uploading && (
|
|
<div className="space-y-4">
|
|
<div className="mx-auto w-20 h-20 bg-gradient-to-br from-blue-100 to-purple-100 rounded-full flex items-center justify-center">
|
|
<FileText className="w-10 h-10 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-semibold text-gray-700 mb-1">
|
|
Cliquez pour choisir un fichier
|
|
</p>
|
|
<p className="text-sm text-gray-500">
|
|
ou glissez-déposez votre PDF ici
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center justify-center gap-2 text-xs text-gray-400">
|
|
<span className="px-3 py-1 bg-white rounded-full border border-gray-200">
|
|
PDF uniquement
|
|
</span>
|
|
<span className="px-3 py-1 bg-white rounded-full border border-gray-200">
|
|
Max 10 Mo
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{uploading && (
|
|
<div className="space-y-4">
|
|
<div className="mx-auto w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center animate-pulse">
|
|
<Upload className="w-10 h-10 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-semibold text-blue-600 mb-2">
|
|
Upload en cours...
|
|
</p>
|
|
{selectedFileName && (
|
|
<p className="text-sm text-gray-600 font-medium mb-3 px-4 break-all">
|
|
{selectedFileName}
|
|
</p>
|
|
)}
|
|
<div className="w-full max-w-xs mx-auto">
|
|
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
|
<span className="font-medium">Progression</span>
|
|
<span className="font-bold text-blue-600">{uploadProgress}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden shadow-inner">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-blue-500 to-purple-600 rounded-full transition-all duration-300 ease-out shadow-lg"
|
|
style={{ width: `${uploadProgress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{fileData && !uploading && (
|
|
<div className="space-y-4">
|
|
<div className="mx-auto w-20 h-20 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center">
|
|
<FileText className="w-10 h-10 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-semibold text-green-600 mb-1">
|
|
✓ Fichier prêt
|
|
</p>
|
|
<p className="text-sm text-gray-600 font-medium break-all px-4">
|
|
{fileData.nomFichier}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
{(fileData.tailleFichier / 1024 / 1024).toFixed(2)} Mo
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setFileData(null);
|
|
}}
|
|
>
|
|
Changer de fichier
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<input
|
|
type="file"
|
|
accept=".pdf"
|
|
className="hidden"
|
|
disabled={uploading}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Afficher IMMÉDIATEMENT le nom du fichier sélectionné
|
|
setSelectedFileName(file.name);
|
|
|
|
// Validation du type de fichier
|
|
if (file.type !== 'application/pdf') {
|
|
toast.error('Seuls les fichiers PDF sont acceptés');
|
|
setSelectedFileName(null);
|
|
return;
|
|
}
|
|
|
|
// Validation de la taille du fichier
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
toast.error('Le fichier ne doit pas dépasser 10 Mo');
|
|
setSelectedFileName(null);
|
|
return;
|
|
}
|
|
|
|
// Afficher immédiatement l'état de chargement
|
|
setUploading(true);
|
|
setUploadProgress(0);
|
|
|
|
// Utiliser requestAnimationFrame pour garantir que le navigateur
|
|
// affiche l'animation AVANT de bloquer sur le fetch
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(async () => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const progressInterval = setInterval(() => {
|
|
setUploadProgress(prev => {
|
|
if (prev >= 90) return prev;
|
|
return prev + 10;
|
|
});
|
|
}, 200);
|
|
|
|
const response = await fetch('/api/upload-file', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
clearInterval(progressInterval);
|
|
setUploadProgress(100);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Erreur lors de l\'upload');
|
|
}
|
|
|
|
const data = await response.json();
|
|
setFileData({
|
|
nomFichier: file.name,
|
|
urlFichier: data.url,
|
|
s3Key: data.key,
|
|
typeFichier: file.type,
|
|
tailleFichier: file.size,
|
|
});
|
|
toast.success('Fichier uploadé avec succès');
|
|
} catch (error) {
|
|
toast.error('Erreur lors de l\'upload du fichier');
|
|
setUploadProgress(0);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}); // Fermer requestAnimationFrame interne
|
|
}); // Fermer requestAnimationFrame externe
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
setFileData(null);
|
|
setUploadProgress(0);
|
|
}}
|
|
disabled={uploading}
|
|
className="px-6 border-2 hover:bg-gray-50"
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={handleUpload}
|
|
disabled={!fileData || uploading}
|
|
className="px-8 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
|
|
>
|
|
{uploading ? 'Chargement...' : '✓ Valider'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export default function AdminGestionAttestations() {
|
|
const [selectedFormationId, setSelectedFormationId] = useState<number | null>(null);
|
|
const [modeAttestation, setModeAttestation] = useState<"auto" | "manuel">("auto");
|
|
const [modeEnvoi, setModeEnvoi] = useState<"auto" | "manuel">("manuel");
|
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
|
const [selectedInscriptionId, setSelectedInscriptionId] = useState<number | null>(null);
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
|
const [filterStatus, setFilterStatus] = useState<"all" | "completed" | "upcoming">("all");
|
|
|
|
// Récupérer la liste des formations
|
|
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery();
|
|
|
|
// Récupérer toutes les séquences pour obtenir les dates
|
|
const { data: allSequences } = trpc.sequences.list.useQuery();
|
|
|
|
// Récupérer la configuration de la formation sélectionnée
|
|
const { data: formationConfig, refetch: refetchConfig } = trpc.gestionAttestations.getFormationConfig.useQuery(
|
|
{ formationId: selectedFormationId! },
|
|
{ enabled: !!selectedFormationId }
|
|
);
|
|
|
|
// Récupérer les apprenants avec leur statut d'attestation
|
|
const { data: apprenants, isLoading: loadingApprenants, refetch: refetchApprenants } =
|
|
trpc.gestionAttestations.getApprenantsWithStatus.useQuery(
|
|
{ formationId: selectedFormationId! },
|
|
{ enabled: !!selectedFormationId }
|
|
);
|
|
|
|
// Les dates sont déjà incluses dans les séquences
|
|
|
|
// Mutation pour mettre à jour la configuration
|
|
const updateConfigMutation = trpc.gestionAttestations.updateFormationConfig.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Configuration enregistrée avec succès");
|
|
refetchConfig();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Erreur : ${error.message}`);
|
|
},
|
|
});
|
|
|
|
// Mutation pour envoyer une attestation
|
|
const sendAttestationMutation = trpc.gestionAttestations.sendAttestation.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Attestation envoyée avec succès");
|
|
refetchApprenants();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Erreur lors de l'envoi : ${error.message}`);
|
|
},
|
|
});
|
|
|
|
// Mutation pour supprimer un document
|
|
const deleteDocumentMutation = trpc.gestionAttestations.deleteDocument.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Document supprimé avec succès");
|
|
refetchApprenants();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Erreur lors de la suppression : ${error.message}`);
|
|
},
|
|
});
|
|
|
|
// Mutation pour envoyer toutes les attestations
|
|
const sendAllMutation = trpc.gestionAttestations.sendAllAttestations.useMutation({
|
|
onSuccess: (result) => {
|
|
toast.success(`${result.succes} attestation(s) envoyée(s) avec succès${result.echecs > 0 ? `, ${result.echecs} échec(s)` : ''}`);
|
|
refetchApprenants();
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Erreur lors de l'envoi en masse : ${error.message}`);
|
|
},
|
|
});
|
|
|
|
// Charger la configuration quand une formation est sélectionnée
|
|
const handleFormationChange = (formationId: string) => {
|
|
const id = parseInt(formationId);
|
|
setSelectedFormationId(id);
|
|
|
|
// Charger la configuration existante
|
|
const config = formations?.find(f => f.id === id);
|
|
if (config) {
|
|
setModeAttestation((config as any).modeAttestation || "auto");
|
|
setModeEnvoi((config as any).modeEnvoi || "manuel");
|
|
}
|
|
};
|
|
|
|
// Enregistrer la configuration
|
|
const handleSaveConfig = () => {
|
|
if (!selectedFormationId) return;
|
|
|
|
updateConfigMutation.mutate({
|
|
formationId: selectedFormationId,
|
|
modeAttestation,
|
|
modeEnvoi,
|
|
});
|
|
};
|
|
|
|
// Prévisualiser un document
|
|
const handlePreview = (url: string) => {
|
|
setPreviewUrl(url);
|
|
setPreviewDialogOpen(true);
|
|
};
|
|
|
|
// Envoyer une attestation
|
|
const handleSendAttestation = (apprenant: any) => {
|
|
const pdfUrl = apprenant.attestation?.documentUrl || apprenant.attestation?.urlPdf;
|
|
|
|
if (!pdfUrl) {
|
|
toast.error("Aucune attestation disponible pour cet apprenant");
|
|
return;
|
|
}
|
|
|
|
sendAttestationMutation.mutate({
|
|
attestationId: apprenant.attestation.id,
|
|
sequenceId: apprenant.sequence.id,
|
|
apprenantId: apprenant.apprenant.id,
|
|
apprenantEmail: apprenant.apprenant.email,
|
|
apprenantNom: apprenant.apprenant.nom,
|
|
apprenantPrenom: apprenant.apprenant.prenom,
|
|
formationNom: formations?.find(f => f.id === selectedFormationId)?.nom || "",
|
|
pdfUrl: pdfUrl,
|
|
});
|
|
};
|
|
|
|
const handleDeleteDocument = (attestationId: number) => {
|
|
if (confirm("Êtes-vous sûr de vouloir supprimer ce document ?")) {
|
|
deleteDocumentMutation.mutate({ attestationId });
|
|
}
|
|
};
|
|
|
|
// Envoyer toutes les attestations
|
|
const handleSendAll = () => {
|
|
if (!selectedFormationId) return;
|
|
|
|
// Compter le nombre d'attestations prêtes à envoyer
|
|
const attestationsReady = apprenants?.filter(item => {
|
|
const hasDocument = item.attestation && (item.attestation.urlPdf || item.attestation.documentUrl);
|
|
return hasDocument;
|
|
}).length || 0;
|
|
|
|
if (attestationsReady === 0) {
|
|
toast.error("Aucune attestation prête à envoyer");
|
|
return;
|
|
}
|
|
|
|
if (confirm(`Êtes-vous sûr de vouloir envoyer ${attestationsReady} attestation(s) ?`)) {
|
|
sendAllMutation.mutate({ formationId: selectedFormationId });
|
|
}
|
|
};
|
|
|
|
// Envoyer toutes les attestations d'une séquence
|
|
const handleSendSequence = (sequenceId: number, sequenceApprenants: any[]) => {
|
|
const attestationsWithDocument = sequenceApprenants.filter(item => {
|
|
const hasDocument = item.attestation && (item.attestation.documentUrl || item.attestation.urlPdf);
|
|
return hasDocument;
|
|
});
|
|
|
|
if (attestationsWithDocument.length === 0) {
|
|
toast.error("Aucune attestation disponible pour cette séquence");
|
|
return;
|
|
}
|
|
|
|
const attestationsNotSent = attestationsWithDocument.filter(item => !item.attestation.emailEnvoye);
|
|
const attestationsAlreadySent = attestationsWithDocument.filter(item => item.attestation.emailEnvoye);
|
|
|
|
let confirmMessage = '';
|
|
if (attestationsNotSent.length > 0 && attestationsAlreadySent.length > 0) {
|
|
confirmMessage = `Êtes-vous sûr de vouloir envoyer ${attestationsNotSent.length} attestation(s) et renvoyer ${attestationsAlreadySent.length} attestation(s) déjà envoyée(s) ?`;
|
|
} else if (attestationsNotSent.length > 0) {
|
|
confirmMessage = `Êtes-vous sûr de vouloir envoyer ${attestationsNotSent.length} attestation(s) pour cette séquence ?`;
|
|
} else {
|
|
confirmMessage = `Êtes-vous sûr de vouloir renvoyer ${attestationsAlreadySent.length} attestation(s) déjà envoyée(s) ?`;
|
|
}
|
|
|
|
if (!confirm(confirmMessage)) {
|
|
return;
|
|
}
|
|
|
|
// Envoyer chaque attestation de la séquence
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
|
|
const sendPromises = attestationsWithDocument.map(item => {
|
|
const pdfUrl = item.attestation.documentUrl || item.attestation.urlPdf;
|
|
return sendAttestationMutation.mutateAsync({
|
|
attestationId: item.attestation.id,
|
|
sequenceId: item.sequence.id,
|
|
apprenantId: item.apprenant.id,
|
|
apprenantEmail: item.apprenant.email,
|
|
apprenantNom: item.apprenant.nom,
|
|
apprenantPrenom: item.apprenant.prenom,
|
|
formationNom: formations?.find(f => f.id === selectedFormationId)?.nom || "",
|
|
pdfUrl: pdfUrl,
|
|
}).then(() => {
|
|
successCount++;
|
|
}).catch(() => {
|
|
errorCount++;
|
|
});
|
|
});
|
|
|
|
Promise.all(sendPromises).finally(() => {
|
|
if (successCount > 0) {
|
|
toast.success(`${successCount} attestation(s) envoyée(s) avec succès${errorCount > 0 ? `, ${errorCount} échec(s)` : ''}`);
|
|
}
|
|
refetchApprenants();
|
|
});
|
|
};
|
|
|
|
// Ouvrir le dialogue d'upload
|
|
const handleOpenUpload = (inscriptionId: number) => {
|
|
setSelectedInscriptionId(inscriptionId);
|
|
setUploadDialogOpen(true);
|
|
};
|
|
|
|
// Calculer les informations de séquence avec dates et tri
|
|
const sequencesWithDates = useMemo(() => {
|
|
if (!apprenants || !allSequences) return [];
|
|
|
|
// Regrouper par séquence
|
|
const grouped = apprenants.reduce((acc: Record<string, typeof apprenants>, item) => {
|
|
const sequenceId = item.sequence.id.toString();
|
|
if (!acc[sequenceId]) acc[sequenceId] = [];
|
|
acc[sequenceId].push(item);
|
|
return acc;
|
|
}, {});
|
|
|
|
// Ajouter les dates et calculer le statut
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
return Object.entries(grouped).map(([sequenceId, sequenceApprenants]) => {
|
|
const sequenceIdNum = parseInt(sequenceId);
|
|
const sequence = allSequences.find(s => s.id === sequenceIdNum);
|
|
const dates = sequence?.dates || [];
|
|
|
|
// Trouver la première et dernière date
|
|
const sortedDates = dates
|
|
.map(d => new Date(d.dateDebut))
|
|
.sort((a, b) => a.getTime() - b.getTime());
|
|
|
|
const sortedEndDates = dates
|
|
.map(d => new Date(d.dateFin))
|
|
.sort((a, b) => a.getTime() - b.getTime());
|
|
|
|
const firstDate = sortedDates.length > 0 ? sortedDates[0] : null;
|
|
const lastDate = sortedEndDates.length > 0 ? sortedEndDates[sortedEndDates.length - 1] : null;
|
|
|
|
// Déterminer si la séquence est terminée
|
|
const isCompleted = lastDate ? lastDate < today : false;
|
|
|
|
return {
|
|
sequenceId: sequenceIdNum,
|
|
sequenceName: sequenceApprenants[0]?.sequence?.nom || 'Séquence inconnue',
|
|
apprenants: sequenceApprenants,
|
|
firstDate,
|
|
lastDate,
|
|
isCompleted,
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
// Tri chronologique par première date
|
|
if (!a.firstDate && !b.firstDate) return a.sequenceId - b.sequenceId;
|
|
if (!a.firstDate) return 1;
|
|
if (!b.firstDate) return -1;
|
|
return a.firstDate.getTime() - b.firstDate.getTime();
|
|
});
|
|
}, [apprenants, allSequences]);
|
|
|
|
// Filtrer les séquences selon le filtre sélectionné
|
|
const filteredSequences = useMemo(() => {
|
|
if (filterStatus === "all") return sequencesWithDates;
|
|
if (filterStatus === "completed") return sequencesWithDates.filter(s => s.isCompleted);
|
|
if (filterStatus === "upcoming") return sequencesWithDates.filter(s => !s.isCompleted);
|
|
return sequencesWithDates;
|
|
}, [sequencesWithDates, filterStatus]);
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="page-title">Gestion des attestations de formation</h1>
|
|
<p className="text-muted-foreground">
|
|
Configurez le mode de génération et d'envoi des attestations pour chaque formation
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sélection de la formation */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Sélectionner une formation</CardTitle>
|
|
<CardDescription>
|
|
Choisissez la formation pour laquelle vous souhaitez gérer les attestations
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Select
|
|
value={selectedFormationId?.toString() || ""}
|
|
onValueChange={handleFormationChange}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Sélectionner une formation" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{formations?.map((formation) => (
|
|
<SelectItem key={formation.id} value={formation.id.toString()}>
|
|
{formation.nom}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Configuration */}
|
|
{selectedFormationId && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Configuration de la formation</CardTitle>
|
|
<CardDescription>
|
|
Définissez comment les attestations seront générées et envoyées
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Mode de génération */}
|
|
<div className="space-y-3">
|
|
<Label className="text-base font-semibold">Mode de génération des attestations</Label>
|
|
<RadioGroup value={modeAttestation} onValueChange={(value: "auto" | "manuel") => setModeAttestation(value)}>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="auto" id="auto" />
|
|
<Label htmlFor="auto" className="font-normal cursor-pointer">
|
|
<div>
|
|
<div className="font-medium">Génération automatique</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
Les attestations sont générées automatiquement à partir du modèle configuré
|
|
</div>
|
|
</div>
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="manuel" id="manuel" />
|
|
<Label htmlFor="manuel" className="font-normal cursor-pointer">
|
|
<div>
|
|
<div className="font-medium">Import manuel</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
Vous uploadez un document PDF pour chaque apprenant
|
|
</div>
|
|
</div>
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
{/* Mode d'envoi */}
|
|
<div className="space-y-3">
|
|
<Label className="text-base font-semibold">Mode d'envoi des attestations</Label>
|
|
<RadioGroup value={modeEnvoi} onValueChange={(value: "auto" | "manuel") => setModeEnvoi(value)}>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="auto" id="envoi-auto" />
|
|
<Label htmlFor="envoi-auto" className="font-normal cursor-pointer">
|
|
<div>
|
|
<div className="font-medium">Envoi automatique</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
Les attestations sont envoyées automatiquement par email aux apprenants
|
|
</div>
|
|
</div>
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="manuel" id="envoi-manuel" />
|
|
<Label htmlFor="envoi-manuel" className="font-normal cursor-pointer">
|
|
<div>
|
|
<div className="font-medium">Envoi manuel</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
Vous décidez quand envoyer chaque attestation via un bouton
|
|
</div>
|
|
</div>
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<Button onClick={handleSaveConfig} disabled={updateConfigMutation.isPending}>
|
|
{updateConfigMutation.isPending ? "Enregistrement..." : "Enregistrer la configuration"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Liste des apprenants */}
|
|
{selectedFormationId && (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Apprenants inscrits</CardTitle>
|
|
<CardDescription>
|
|
Gérez les attestations pour chaque apprenant inscrit à cette formation
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Filtre */}
|
|
<Select value={filterStatus} onValueChange={(value: any) => setFilterStatus(value)}>
|
|
<SelectTrigger className="w-[200px]">
|
|
<Filter className="h-4 w-4 mr-2" />
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Toutes les séquences</SelectItem>
|
|
<SelectItem value="completed">Séquences terminées</SelectItem>
|
|
<SelectItem value="upcoming">Séquences à venir</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Bouton envoi global */}
|
|
{modeEnvoi === "manuel" && apprenants && apprenants.length > 0 && (
|
|
<Button
|
|
onClick={handleSendAll}
|
|
disabled={sendAllMutation.isPending}
|
|
variant="default"
|
|
>
|
|
<Send className="h-4 w-4 mr-2" />
|
|
{sendAllMutation.isPending ? "Envoi en cours..." : "Tout renvoyer"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loadingApprenants ? (
|
|
<p className="text-center text-muted-foreground py-8">Chargement...</p>
|
|
) : filteredSequences && filteredSequences.length > 0 ? (
|
|
<div className="space-y-6">
|
|
{filteredSequences.map(({ sequenceId, sequenceName, apprenants: sequenceApprenants, firstDate, isCompleted }) => {
|
|
const attestationsEnvoyees = sequenceApprenants.filter(a => a.attestation?.emailEnvoye).length;
|
|
const attestationsPretesNonEnvoyees = sequenceApprenants.filter(a => {
|
|
const hasDocument = a.attestation && (a.attestation.documentUrl || a.attestation.urlPdf);
|
|
return hasDocument && !a.attestation.emailEnvoye;
|
|
}).length;
|
|
|
|
return (
|
|
<div key={sequenceId} className="border rounded-lg overflow-hidden">
|
|
{/* En-tête de séquence */}
|
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="font-semibold text-lg text-blue-900">{sequenceName}</h3>
|
|
{isCompleted && (
|
|
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-300">
|
|
Terminée
|
|
</Badge>
|
|
)}
|
|
{!isCompleted && (
|
|
<Badge variant="outline" className="bg-orange-100 text-orange-700 border-orange-300">
|
|
À venir
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 mt-1">
|
|
<p className="text-sm text-blue-700">{sequenceApprenants.length} apprenant(s)</p>
|
|
{firstDate && (
|
|
<div className="flex items-center gap-1 text-sm text-blue-600">
|
|
<Calendar className="h-3 w-3" />
|
|
<span>Début : {firstDate.toLocaleDateString('fr-FR')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="bg-white">
|
|
{attestationsEnvoyees} / {sequenceApprenants.length} envoyé(es)
|
|
</Badge>
|
|
{modeEnvoi === "manuel" && sequenceApprenants.some(a => a.attestation && (a.attestation.documentUrl || a.attestation.urlPdf)) && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleSendSequence(sequenceId, sequenceApprenants)}
|
|
disabled={sendAllMutation.isPending}
|
|
className="bg-green-600 hover:bg-green-700 text-white"
|
|
>
|
|
<Send className="h-3 w-3 mr-1" />
|
|
{attestationsPretesNonEnvoyees > 0
|
|
? `Envoyer la séquence (${attestationsPretesNonEnvoyees})`
|
|
: `Renvoyer la séquence (${attestationsEnvoyees})`
|
|
}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tableau des apprenants de cette séquence */}
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Nom</TableHead>
|
|
<TableHead>Prénom</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Statut attestation</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sequenceApprenants.map((item, index) => {
|
|
const hasAttestation = !!item.attestation;
|
|
const hasDocument = hasAttestation && (item.attestation.urlPdf || item.attestation.documentUrl);
|
|
const isSent = hasAttestation && item.attestation.emailEnvoye;
|
|
const documentUrl = item.attestation?.documentUrl || item.attestation?.urlPdf;
|
|
|
|
return (
|
|
<TableRow key={item.inscription.id} className={index % 2 === 1 ? "bg-blue-50/30" : ""}>
|
|
<TableCell>{item.apprenant.nom}</TableCell>
|
|
<TableCell>{item.apprenant.prenom}</TableCell>
|
|
<TableCell>{item.apprenant.email}</TableCell>
|
|
<TableCell>
|
|
{!hasDocument && (
|
|
<Badge variant="outline" className="bg-gray-50">
|
|
Aucune attestation
|
|
</Badge>
|
|
)}
|
|
{hasDocument && !isSent && (
|
|
<Badge variant="outline" className="bg-orange-50 text-orange-700 border-orange-300">
|
|
Prête à envoyer
|
|
</Badge>
|
|
)}
|
|
{hasDocument && isSent && (
|
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-300">
|
|
Envoyée
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
{modeAttestation === "manuel" && (
|
|
<Button
|
|
size="sm"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
onClick={() => handleOpenUpload(item.inscription.id)}
|
|
>
|
|
<Upload className="h-4 w-4 mr-1" />
|
|
{hasDocument ? "Remplacer" : "Charger"}
|
|
</Button>
|
|
)}
|
|
{hasDocument && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
onClick={() => handlePreview(documentUrl!)}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
{modeEnvoi === "manuel" && (
|
|
<Button
|
|
size="sm"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
onClick={() => handleSendAttestation(item)}
|
|
disabled={sendAttestationMutation.isPending}
|
|
>
|
|
<Send className="h-4 w-4 mr-1" />
|
|
{isSent ? "Renvoyer" : "Envoyer"}
|
|
</Button>
|
|
)}
|
|
{modeAttestation === "manuel" && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleDeleteDocument(item.attestation!.id)}
|
|
disabled={deleteDocumentMutation.isPending}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-red-600" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-3" />
|
|
<p className="text-muted-foreground">
|
|
{filterStatus === "all"
|
|
? "Aucun apprenant inscrit pour cette formation"
|
|
: filterStatus === "completed"
|
|
? "Aucune séquence terminée"
|
|
: "Aucune séquence à venir"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Dialog d'upload */}
|
|
<UploadAttestationDialog
|
|
open={uploadDialogOpen}
|
|
onOpenChange={setUploadDialogOpen}
|
|
inscriptionId={selectedInscriptionId}
|
|
onSuccess={() => {
|
|
refetchApprenants();
|
|
setUploadDialogOpen(false);
|
|
toast.success("Attestation chargée avec succès");
|
|
}}
|
|
/>
|
|
|
|
{/* Dialog de prévisualisation */}
|
|
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
|
<DialogContent className="max-w-4xl h-[80vh]">
|
|
<DialogHeader>
|
|
<DialogTitle>Prévisualisation de l'attestation</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-hidden">
|
|
{previewUrl && (
|
|
<iframe
|
|
src={previewUrl}
|
|
className="w-full h-full border-0"
|
|
title="Prévisualisation de l'attestation"
|
|
/>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|