Checkpoint: Implémentation du QR code dédié pour l'émargement formateur avec signature tactile

- Création de la page /emargement-formateur/:token avec formulaire simplifié
- Procédure backend presences.validerFormateur dédiée
- Deux boutons QR code dans l'interface formateur (apprenants + formateur)
- Génération automatique des URL distinctes pour chaque type d'utilisateur
- Déploiement réussi sur le VPS de production
This commit is contained in:
Manus
2026-02-02 15:11:18 -05:00
parent f9983adada
commit 04001e7bcd
11 changed files with 1043 additions and 321 deletions

View File

@@ -5,6 +5,7 @@ import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home"; import Home from "./pages/Home";
import EmargementFormateurScan from "./pages/EmargementFormateurScan";
import LoginChoice from "./pages/LoginChoice"; import LoginChoice from "./pages/LoginChoice";
import AdminRapportPublicCible from "./pages/AdminRapportPublicCible"; import AdminRapportPublicCible from "./pages/AdminRapportPublicCible";
import Admin from "./pages/Admin"; import Admin from "./pages/Admin";
@@ -48,8 +49,9 @@ function Router() {
return ( return (
<Switch> <Switch>
<Route path={"/"} component={LoginChoice} /> <Route path={"/"} component={LoginChoice} />
<Route path={"/home"} component={Home} /> <Route path={"/"} component={Home} />
<Route path={"/login"} component={Login} /> <Route path={"/emargement-formateur/:token"} component={EmargementFormateurScan} />
<Route path={"/404"} component={NotFound} />
<Route path={"/forgot-password"} component={ForgotPassword} /> <Route path={"/forgot-password"} component={ForgotPassword} />
<Route path={"/reset-password"} component={ResetPassword} /> <Route path={"/reset-password"} component={ResetPassword} />
<Route path={"/admin/rapport-public-cible"} component={AdminRapportPublicCible} /> <Route path={"/admin/rapport-public-cible"} component={AdminRapportPublicCible} />

View File

@@ -0,0 +1,220 @@
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 { trpc } from "@/lib/trpc";
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
import { useState } from "react";
import { useParams } from "wouter";
import { toast } from "sonner";
import SignaturePadComponent from "@/components/SignaturePad";
export default function EmargementFormateurScan() {
const { token } = useParams<{ token: string }>();
const [email, setEmail] = useState("");
const [emailConfirmed, setEmailConfirmed] = useState(false);
const [selectedPeriode, setSelectedPeriode] = useState<"matin" | "apres_midi">("matin");
const [success, setSuccess] = useState(false);
const [heureEnregistrement, setHeureEnregistrement] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const [showSignaturePad, setShowSignaturePad] = useState(false);
// Valider la présence formateur
const { mutate: validerPresence, isPending: validating } = trpc.presences.validerFormateur.useMutation({
onSuccess: () => {
const now = new Date();
setHeureEnregistrement(now);
setSuccess(true);
toast.success("Présence validée avec succès !");
},
onError: (error) => {
setError(error.message);
toast.error("Erreur lors de la validation", {
description: error.message,
});
},
});
const handleSaveSignature = (dataUrl: string) => {
setShowSignaturePad(false);
// Valider automatiquement après la signature
validerPresence({
token: token || "",
email,
periode: selectedPeriode,
signatureDataUrl: dataUrl,
});
};
const handleSearchEmail = () => {
if (!email || !email.includes("@")) {
toast.error("Veuillez saisir un email valide");
return;
}
setEmailConfirmed(true);
};
const handleValider = () => {
setShowSignaturePad(true);
};
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<XCircle className="h-6 w-6" />
Lien invalide
</CardTitle>
<CardDescription>Le lien d'émargement est invalide ou a expiré.</CardDescription>
</CardHeader>
</Card>
</div>
);
}
if (showSignaturePad) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>Signature de présence formateur</CardTitle>
<CardDescription>
Signez pour valider votre présence en tant que formateur
</CardDescription>
</CardHeader>
<CardContent>
<SignaturePadComponent
onSave={handleSaveSignature}
onCancel={() => setShowSignaturePad(false)}
/>
</CardContent>
</Card>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-6 w-6" />
Présence validée
</CardTitle>
<CardDescription>
Votre présence a été enregistrée avec succès
{heureEnregistrement && (
<span className="block mt-2 text-sm">
Heure d'enregistrement : {heureEnregistrement.toLocaleTimeString("fr-FR")}
</span>
)}
</CardDescription>
</CardHeader>
</Card>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-rose-100 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<XCircle className="h-6 w-6" />
Erreur
</CardTitle>
<CardDescription className="text-red-600">{error}</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => window.location.reload()} className="w-full">
Réessayer
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Émargement formateur</CardTitle>
<CardDescription>Validez votre présence en tant que formateur</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{!emailConfirmed ? (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="votre.email@exemple.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearchEmail();
}
}}
/>
</div>
<Button onClick={handleSearchEmail} className="w-full">
Continuer
</Button>
</div>
) : (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">👨🏫</span>
<span className="font-semibold text-blue-900">Mode formateur</span>
</div>
<p className="text-sm text-blue-700">{email}</p>
</div>
<div className="space-y-2">
<Label htmlFor="periode">Période</Label>
<Select
value={selectedPeriode}
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="matin">Matin</SelectItem>
<SelectItem value="apres_midi">Après-midi</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleValider} className="w-full" disabled={validating}>
{validating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validation en cours...
</>
) : (
"Signer ma présence"
)}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -38,13 +38,7 @@ export default function EmargementScan() {
// Récupérer les inscriptions de l'apprenant avec les dates // Récupérer les inscriptions de l'apprenant avec les dates
const { data: inscriptions } = trpc.inscriptions.listByApprenantWithDates.useQuery( const { data: inscriptions } = trpc.inscriptions.listByApprenantWithDates.useQuery(
{ apprenantId: apprenant?.id || 0 }, { apprenantId: apprenant?.id || 0 },
{ enabled: !!apprenant } { enabled: !!apprenant && !isFormateur }
);
// Récupérer les séquences pour le token (pour les formateurs)
const { data: sequenceData } = trpc.presences.getSequenceByToken.useQuery(
{ token: token || "" },
{ enabled: emailConfirmed && isFormateur && !!token }
); );
// Récupérer les dates de formation pour l'inscription sélectionnée // Récupérer les dates de formation pour l'inscription sélectionnée
@@ -86,129 +80,111 @@ export default function EmargementScan() {
setIsFormateur(false); setIsFormateur(false);
setEmailConfirmed(true); setEmailConfirmed(true);
} else { } else {
// Peut-être un formateur, on continue quand même // C'est un formateur
setIsFormateur(true); setIsFormateur(true);
setEmailConfirmed(true); setEmailConfirmed(true);
// Pour les formateurs, on passe directement à la signature
setShowSignaturePad(true);
}
};
const handleValiderWithSignature = (signatureDataUrl: string) => {
if (isFormateur) {
// Pour les formateurs : validation directe avec le token
validerPresence({
token: token || "",
email,
periode: selectedPeriode,
signatureDataUrl,
});
} else {
// Pour les apprenants : validation classique
if (!selectedDateId) {
toast.error("Veuillez sélectionner une date");
return;
}
validerPresence({
token: token || "",
dateId: selectedDateId,
periode: selectedPeriode,
email,
signatureDataUrl,
});
} }
}; };
const handleValider = () => { const handleValider = () => {
if (isFormateur) { if (isFormateur) {
// Pour un formateur, on a juste besoin de la date et de la période // Pour les formateurs, on affiche le pad de signature
if (!selectedDateId) {
toast.error("Veuillez sélectionner une date de formation");
return;
}
} else {
// Pour un apprenant, on a besoin de l'inscription et de la date
if (!selectedInscriptionId || !selectedDateId) {
toast.error("Veuillez sélectionner une date de formation");
return;
}
}
// Afficher le pad de signature
setShowSignaturePad(true); setShowSignaturePad(true);
};
const handleValiderWithSignature = (dataUrl: string) => {
if (isFormateur) {
// Pour un formateur
if (!selectedDateId) {
toast.error("Veuillez sélectionner une date de formation");
return;
}
validerPresence({
token,
email,
dateFormationId: selectedDateId,
periode: selectedPeriode,
modeValidation: "qrcode",
signatureDataUrl: dataUrl,
});
} else { } else {
// Pour un apprenant // Pour les apprenants, vérifier les champs requis
if (!selectedInscriptionId || !selectedDateId) { if (!selectedDateId) {
toast.error("Veuillez sélectionner une date de formation"); toast.error("Veuillez sélectionner une date");
return; return;
} }
setShowSignaturePad(true);
validerPresence({
token,
email,
inscriptionId: selectedInscriptionId,
dateFormationId: selectedDateId,
periode: selectedPeriode,
modeValidation: "qrcode",
signatureDataUrl: dataUrl,
});
} }
}; };
// Déterminer les dates disponibles if (!token) {
const datesDisponibles = isFormateur return (
? (sequenceData?.dates || []) <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
: ((selectedInscription?.sequence as any)?.dates || []); <Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<XCircle className="h-6 w-6" />
Lien invalide
</CardTitle>
<CardDescription>Le lien d'émargement est invalide ou a expiré.</CardDescription>
</CardHeader>
</Card>
</div>
);
}
if (showSignaturePad) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>Signature de présence</CardTitle>
<CardDescription>
{isFormateur
? "Signez pour valider votre présence en tant que formateur"
: "Signez pour valider votre présence"}
</CardDescription>
</CardHeader>
<CardContent>
<SignaturePadComponent
onSave={handleSaveSignature}
onCancel={() => setShowSignaturePad(false)}
/>
</CardContent>
</Card>
</div>
);
}
if (success) { if (success) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 via-emerald-50 to-green-100 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4">
<Card className="max-w-md w-full shadow-2xl border-2 border-green-200 animate-in fade-in zoom-in duration-500"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader>
<div className="mx-auto mb-4 w-20 h-20 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full flex items-center justify-center shadow-lg animate-in zoom-in duration-700 delay-150"> <CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-12 w-12 text-white animate-in zoom-in duration-500 delay-300" /> <CheckCircle2 className="h-6 w-6" />
</div> Présence validée
<CardTitle className="text-3xl font-bold text-green-700 animate-in slide-in-from-bottom duration-500 delay-200">
Présence validée !
</CardTitle> </CardTitle>
<CardDescription className="text-base mt-2 animate-in slide-in-from-bottom duration-500 delay-300"> <CardDescription>
Votre présence a été enregistrée avec succès Votre présence a été enregistrée avec succès
{heureEnregistrement && (
<span className="block mt-2 text-sm">
Heure d'enregistrement : {heureEnregistrement.toLocaleTimeString("fr-FR")}
</span>
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-center space-y-4 animate-in slide-in-from-bottom duration-500 delay-400">
{heureEnregistrement && (
<div className="p-4 bg-green-50 border-2 border-green-200 rounded-lg">
<p className="text-sm font-medium text-green-800 mb-1">
🕒 Heure d'enregistrement
</p>
<p className="text-2xl font-bold text-green-700">
{heureEnregistrement.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</p>
<p className="text-xs text-green-600 mt-1">
{heureEnregistrement.toLocaleDateString("fr-FR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
)}
<p className="text-sm text-muted-foreground">
Merci d'avoir confirmé votre présence. Vous pouvez maintenant fermer cette page.
</p>
<Button
variant="outline"
className="w-full"
onClick={() => {
setSuccess(false);
setHeureEnregistrement(null);
setEmail("");
setEmailConfirmed(false);
setSelectedInscriptionId(null);
setSelectedDateId(null);
setError(null);
setIsFormateur(false);
}}
>
Valider une autre présence
</Button>
</CardContent>
</Card> </Card>
</div> </div>
); );
@@ -216,26 +192,17 @@ export default function EmargementScan() {
if (error) { if (error) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-red-100 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-rose-100 p-4">
<Card className="max-w-md w-full"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader>
<div className="mx-auto mb-4 w-16 h-16 bg-red-500 rounded-full flex items-center justify-center"> <CardTitle className="flex items-center gap-2 text-red-600">
<XCircle className="h-10 w-10 text-white" /> <XCircle className="h-6 w-6" />
</div> Erreur
<CardTitle className="text-2xl text-red-700">Erreur</CardTitle> </CardTitle>
<CardDescription>{error}</CardDescription> <CardDescription className="text-red-600">{error}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-center"> <CardContent>
<Button <Button onClick={() => window.location.reload()} className="w-full">
onClick={() => {
setError(null);
setEmail("");
setEmailConfirmed(false);
setSelectedInscriptionId(null);
setSelectedDateId(null);
setIsFormateur(false);
}}
>
Réessayer Réessayer
</Button> </Button>
</CardContent> </CardContent>
@@ -246,23 +213,20 @@ export default function EmargementScan() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<Card className="max-w-md w-full"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl">Émargement numérique</CardTitle> <CardTitle>Émargement numérique</CardTitle>
<CardDescription> <CardDescription>Validez votre présence en renseignant vos informations</CardDescription>
Validez votre présence en renseignant vos informations
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-6">
{/* Étape 1: Saisie de l'email */} {!emailConfirmed ? (
{!emailConfirmed && ( <div className="space-y-4">
<div className="space-y-3"> <div className="space-y-2">
<div> <Label htmlFor="email">Email</Label>
<Label htmlFor="email">Votre email</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="prenom.nom@exemple.fr" placeholder="votre.email@exemple.com"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -276,55 +240,86 @@ export default function EmargementScan() {
Continuer Continuer
</Button> </Button>
</div> </div>
)} ) : isFormateur ? (
{/* Étape 2: Sélection pour apprenant */}
{emailConfirmed && !isFormateur && apprenant && !success && (
<div className="space-y-4"> <div className="space-y-4">
<div className="p-3 bg-muted rounded-lg"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm font-medium"> <div className="flex items-center gap-2 mb-2">
{apprenant.prenom} {apprenant.nom} <span className="text-2xl">👨🏫</span>
</p> <span className="font-semibold text-blue-900">Mode formateur</span>
<p className="text-xs text-muted-foreground">{apprenant.email}</p> </div>
<p className="text-sm text-blue-700">{email}</p>
</div>
<div className="space-y-2">
<Label htmlFor="periode">Période</Label>
<Select
value={selectedPeriode}
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="matin">Matin</SelectItem>
<SelectItem value="apres_midi">Après-midi</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleValider} className="w-full" disabled={validating}>
{validating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validation en cours...
</>
) : (
"Signer ma présence"
)}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm font-medium text-green-900">{apprenant?.nom} {apprenant?.prenom}</p>
<p className="text-sm text-green-700">{email}</p>
</div> </div>
{inscriptions && inscriptions.length > 0 ? ( {inscriptions && inscriptions.length > 0 ? (
<> <>
<div> <div className="space-y-2">
<Label>Sélectionnez votre formation</Label> <Label htmlFor="inscription">Formation</Label>
<Select <Select
value={selectedInscriptionId?.toString() || ""} value={selectedInscriptionId?.toString()}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedInscriptionId(parseInt(value)); setSelectedInscriptionId(parseInt(value));
setSelectedDateId(null); setSelectedDateId(null);
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Choisir une formation..." /> <SelectValue placeholder="Sélectionnez une formation" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{inscriptions.map((inscr) => ( {inscriptions.map((item) => (
<SelectItem key={inscr.inscription.id} value={inscr.inscription.id.toString()}> <SelectItem key={item.inscription.id} value={item.inscription.id.toString()}>
{inscr.sequence?.nom || "Formation"} {item.sequence.formation.nom} - {item.sequence.nom}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{selectedInscriptionId && datesDisponibles.length > 0 && ( {selectedInscription && (
<> <div className="space-y-2">
<div> <Label htmlFor="date">Date</Label>
<Label>Sélectionnez la date</Label>
<Select <Select
value={selectedDateId?.toString() || ""} value={selectedDateId?.toString()}
onValueChange={(value) => setSelectedDateId(parseInt(value))} onValueChange={(value) => setSelectedDateId(parseInt(value))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Choisir une date..." /> <SelectValue placeholder="Sélectionnez une date" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{datesDisponibles.map((date: any) => ( {selectedInscription.dates.map((date) => (
<SelectItem key={date.id} value={date.id.toString()}> <SelectItem key={date.id} value={date.id.toString()}>
{new Date(date.dateDebut).toLocaleDateString("fr-FR", { {new Date(date.dateDebut).toLocaleDateString("fr-FR", {
weekday: "long", weekday: "long",
@@ -337,9 +332,10 @@ export default function EmargementScan() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
<div> <div className="space-y-2">
<Label>Période</Label> <Label htmlFor="periode">Période</Label>
<Select <Select
value={selectedPeriode} value={selectedPeriode}
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)} onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
@@ -354,132 +350,30 @@ export default function EmargementScan() {
</Select> </Select>
</div> </div>
<Button onClick={handleValider} className="w-full" disabled={validating}> <Button
onClick={handleValider}
className="w-full"
disabled={!selectedDateId || validating}
>
{validating ? ( {validating ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validation en cours... Validation en cours...
</> </>
) : ( ) : (
"Signer et valider" "Valider ma présence"
)} )}
</Button> </Button>
</> </>
)}
</>
) : ( ) : (
<p className="text-center text-muted-foreground py-4"> <div className="text-center py-8 text-gray-500">
Aucune inscription trouvée pour cet email <p>Aucune inscription trouvée pour cet email.</p>
</p>
)}
</div>
)}
{/* Étape 2: Sélection pour formateur */}
{emailConfirmed && isFormateur && !success && (
<div className="space-y-4">
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm font-medium text-blue-900">
👨🏫 Mode formateur
</p>
<p className="text-xs text-blue-700">{email}</p>
</div>
{sequenceData && sequenceData.dates && sequenceData.dates.length > 0 ? (
<>
<div>
<Label>Formation</Label>
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm font-medium">{sequenceData.nom}</p>
<p className="text-xs text-muted-foreground">{sequenceData.lieu}</p>
</div>
</div>
<div>
<Label>Sélectionnez la date</Label>
<Select
value={selectedDateId?.toString() || ""}
onValueChange={(value) => setSelectedDateId(parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="Choisir une date..." />
</SelectTrigger>
<SelectContent>
{sequenceData.dates.map((date: any) => (
<SelectItem key={date.id} value={date.id.toString()}>
{new Date(date.dateDebut).toLocaleDateString("fr-FR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedDateId && (
<>
<div>
<Label>Période</Label>
<Select
value={selectedPeriode}
onValueChange={(value: "matin" | "apres_midi") => setSelectedPeriode(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="matin">Matin</SelectItem>
<SelectItem value="apres_midi">Après-midi</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleValider} className="w-full" disabled={validating}>
{validating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validation en cours...
</>
) : (
"Signer et valider"
)}
</Button>
</>
)}
</>
) : (
<div className="text-center py-4">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
<p className="text-sm text-muted-foreground">Chargement des informations...</p>
</div> </div>
)} )}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Dialog de signature */}
{showSignaturePad && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<Card className="max-w-2xl w-full">
<CardHeader>
<CardTitle>Signature</CardTitle>
<CardDescription>
Signez dans le cadre ci-dessous pour valider votre présence
</CardDescription>
</CardHeader>
<CardContent>
<SignaturePadComponent
onSave={handleSaveSignature}
onCancel={() => setShowSignaturePad(false)}
/>
</CardContent>
</Card>
</div>
)}
</div> </div>
); );
} }

View File

@@ -26,13 +26,14 @@ export default function FormateurEmargement() {
const { user } = useAuth(); const { user } = useAuth();
const [selectedSequenceId, setSelectedSequenceId] = useState<number | null>(null); const [selectedSequenceId, setSelectedSequenceId] = useState<number | null>(null);
const [showQRDialog, setShowQRDialog] = useState(false); const [showQRDialog, setShowQRDialog] = useState(false);
const [showQRFormateurDialog, setShowQRFormateurDialog] = useState(false);
const [lastRefreshTime, setLastRefreshTime] = useState<Date>(new Date()); const [lastRefreshTime, setLastRefreshTime] = useState<Date>(new Date());
const [secondsSinceRefresh, setSecondsSinceRefresh] = useState(0); const [secondsSinceRefresh, setSecondsSinceRefresh] = useState(0);
// Récupérer toutes les séquences (filtrées côté serveur pour les formateurs) // Récupérer toutes les séquences (filtrées côté serveur pour les formateurs)
const { data: sequences, isLoading: loadingSequences } = trpc.sequences.list.useQuery(); const { data: sequences, isLoading: loadingSequences } = trpc.sequences.list.useQuery();
// Générer le QR code // Générer le QR code apprenants
const { data: qrData, mutate: generateQR, isPending: generatingQR } = trpc.presences.generateQRCode.useMutation({ const { data: qrData, mutate: generateQR, isPending: generatingQR } = trpc.presences.generateQRCode.useMutation({
onSuccess: () => { onSuccess: () => {
setShowQRDialog(true); setShowQRDialog(true);
@@ -44,6 +45,18 @@ export default function FormateurEmargement() {
}, },
}); });
// Générer le QR code formateur
const { data: qrFormateurData, mutate: generateQRFormateur, isPending: generatingQRFormateur } = trpc.presences.generateQRCodeFormateur.useMutation({
onSuccess: () => {
setShowQRFormateurDialog(true);
},
onError: (error) => {
toast.error("Erreur lors de la génération du QR code formateur", {
description: error.message,
});
},
});
// Récupérer les inscrits avec les dates de formation // Récupérer les inscrits avec les dates de formation
const { data: inscrits, isLoading: loadingInscrits, refetch: refetchInscrits } = trpc.inscriptions.listWithDates.useQuery( const { data: inscrits, isLoading: loadingInscrits, refetch: refetchInscrits } = trpc.inscriptions.listWithDates.useQuery(
{ sequenceId: selectedSequenceId || 0 }, { sequenceId: selectedSequenceId || 0 },
@@ -121,6 +134,15 @@ export default function FormateurEmargement() {
generateQR({ sequenceId: selectedSequenceId }); generateQR({ sequenceId: selectedSequenceId });
}; };
const handleGenerateQRFormateur = () => {
if (!selectedSequenceId) {
toast.error("Veuillez sélectionner une séquence");
return;
}
// Générer le QR code formateur
generateQRFormateur({ sequenceId: selectedSequenceId });
};
const handleValiderPresence = (inscriptionId: number, dateFormationId: number, periode: "matin" | "apres_midi") => { const handleValiderPresence = (inscriptionId: number, dateFormationId: number, periode: "matin" | "apres_midi") => {
validerPresence({ validerPresence({
inscriptionId, inscriptionId,
@@ -209,7 +231,7 @@ export default function FormateurEmargement() {
{selectedSequenceId && ( {selectedSequenceId && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
<Button onClick={handleGenerateQR} disabled={generatingQR}> <Button onClick={handleGenerateQR} disabled={generatingQR}>
{generatingQR ? ( {generatingQR ? (
<> <>
@@ -219,7 +241,20 @@ export default function FormateurEmargement() {
) : ( ) : (
<> <>
<QrCode className="mr-2 h-4 w-4" /> <QrCode className="mr-2 h-4 w-4" />
Afficher le QR code QR Code Apprenants
</>
)}
</Button>
<Button onClick={handleGenerateQRFormateur} disabled={generatingQRFormateur} variant="secondary">
{generatingQRFormateur ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Génération...
</>
) : (
<>
<QrCode className="mr-2 h-4 w-4" />
QR Code Formateur
</> </>
)} )}
</Button> </Button>
@@ -451,13 +486,13 @@ export default function FormateurEmargement() {
</Card> </Card>
)} )}
{/* Dialog QR Code */} {/* Dialog QR Code Apprenants */}
<Dialog open={showQRDialog} onOpenChange={setShowQRDialog}> <Dialog open={showQRDialog} onOpenChange={setShowQRDialog}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>QR Code d'émargement</DialogTitle> <DialogTitle>QR Code d'émargement apprenants</DialogTitle>
<DialogDescription> <DialogDescription>
Les apprenants et formateurs doivent scanner ce QR code pour valider leur présence avec signature tactile Les apprenants doivent scanner ce QR code pour valider leur présence avec signature tactile
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{qrData && ( {qrData && (
@@ -475,6 +510,38 @@ export default function FormateurEmargement() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Dialog QR Code Formateur */}
<Dialog open={showQRFormateurDialog} onOpenChange={setShowQRFormateurDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>QR Code d'émargement formateur</DialogTitle>
<DialogDescription>
Scannez ce QR code avec votre smartphone pour émarger en tant que formateur avec signature tactile
</DialogDescription>
</DialogHeader>
{qrFormateurData && (
<div className="flex flex-col items-center gap-4 py-4">
<img
src={qrFormateurData.qrCodeDataURL}
alt="QR Code Formateur"
className="w-full max-w-[300px] border rounded-lg"
/>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 w-full">
<p className="text-sm font-semibold text-blue-900 mb-2">👨‍🏫 Mode formateur
</p>
<p className="text-xs text-blue-700">
Ce QR code vous redirigera vers une page dédiée pour l'émargement formateur.
Vous pourrez signer votre présence (matin/après-midi) directement depuis votre smartphone.
</p>
</div>
<p className="text-sm text-muted-foreground text-center">
Affichez ce QR code en plein écran pour faciliter le scan
</p>
</div>
)}
</DialogContent>
</Dialog>
{/* Dialog Signature */} {/* Dialog Signature */}
<Dialog open={showSignatureDialog} onOpenChange={setShowSignatureDialog}> <Dialog open={showSignatureDialog} onOpenChange={setShowSignatureDialog}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">

Binary file not shown.

View File

@@ -0,0 +1,72 @@
// Correction pour validerPresenceFormateur - à intégrer dans presenceDb.ts ligne 189-256
export async function validerPresenceFormateur(params: {
formateurId: number;
sequenceId: number;
dateFormationId?: number;
periode: "matin" | "apres_midi";
modeValidation: "qrcode" | "manuel";
commentaire?: string;
signatureUrl?: string;
signatureS3Key?: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Si dateFormationId est fourni, vérifier la date
if (params.dateFormationId) {
const dateFormation = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.id, params.dateFormationId))
.limit(1);
if (dateFormation.length === 0) {
throw new Error("Date de formation introuvable");
}
// Vérifier que la date actuelle est le jour de la formation ou après
const now = new Date();
const dateDebut = new Date(dateFormation[0].dateDebut);
// Réinitialiser les heures pour comparer uniquement les dates
now.setHours(0, 0, 0, 0);
dateDebut.setHours(0, 0, 0, 0);
if (now < dateDebut) {
throw new Error("Vous ne pouvez pas émarger avant la date de formation");
}
// Vérifier si la présence existe déjà pour cette période
const presenceExistante = await db
.select()
.from(presences)
.where(
and(
eq(presences.formateurId, params.formateurId),
eq(presences.dateFormationId, params.dateFormationId),
sql`${presences.periode} = ${params.periode}`,
sql`${presences.typeUtilisateur} = 'formateur'`
)
);
if (presenceExistante.length > 0) {
throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`);
}
}
// Créer la présence formateur
await db.insert(presences).values({
typeUtilisateur: "formateur",
formateurId: params.formateurId,
dateFormationId: params.dateFormationId || null,
periode: params.periode,
heurePresence: new Date(),
modeValidation: params.modeValidation,
commentaire: params.commentaire,
signatureUrl: params.signatureUrl,
signatureS3Key: params.signatureS3Key,
});
return { success: true };
}

View File

@@ -189,7 +189,7 @@ export async function supprimerPresence(presenceId: number) {
export async function validerPresenceFormateur(params: { export async function validerPresenceFormateur(params: {
formateurId: number; formateurId: number;
sequenceId: number; sequenceId: number;
dateFormationId: number; dateFormationId?: number;
periode: "matin" | "apres_midi"; periode: "matin" | "apres_midi";
modeValidation: "qrcode" | "manuel"; modeValidation: "qrcode" | "manuel";
commentaire?: string; commentaire?: string;

287
server/presenceDb.ts.backup Normal file
View File

@@ -0,0 +1,287 @@
import { eq, and, sql } from "drizzle-orm";
import { presences, inscriptions, datesFormation, sequences, apprenants } from "../drizzle/schema";
import { getDb } from "./db";
/**
* Valider la présence d'un apprenant (par QR code ou manuellement)
*/
export async function validerPresence(params: {
inscriptionId: number;
dateFormationId: number;
periode: "matin" | "apres_midi";
modeValidation: "qrcode" | "manuel";
validateurId?: number;
commentaire?: string;
signatureUrl?: string;
signatureS3Key?: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Récupérer la date de formation
const dateFormation = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.id, params.dateFormationId))
.limit(1);
if (dateFormation.length === 0) {
throw new Error("Date de formation introuvable");
}
// Vérifier que la date actuelle est le jour de la formation ou après
const now = new Date();
const dateDebut = new Date(dateFormation[0].dateDebut);
// Réinitialiser les heures pour comparer uniquement les dates
now.setHours(0, 0, 0, 0);
dateDebut.setHours(0, 0, 0, 0);
if (now < dateDebut) {
throw new Error("Vous ne pouvez pas émarger avant la date de formation");
}
// Vérifier si la présence existe déjà pour cette période
const presenceExistante = await db
.select()
.from(presences)
.where(
and(
eq(presences.inscriptionId, params.inscriptionId),
eq(presences.dateFormationId, params.dateFormationId),
sql`${presences.periode} = ${params.periode}`
)
);
if (presenceExistante.length > 0) {
throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`);
}
// Créer la présence
await db.insert(presences).values({
inscriptionId: params.inscriptionId,
dateFormationId: params.dateFormationId,
periode: params.periode,
heurePresence: new Date(),
modeValidation: params.modeValidation,
validateurId: params.validateurId,
commentaire: params.commentaire,
signatureUrl: params.signatureUrl,
signatureS3Key: params.signatureS3Key,
});
return { success: true };
}
/**
* Récupérer toutes les présences pour une séquence
*/
export async function getPresencesBySequence(sequenceId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({
presenceId: presences.id,
inscriptionId: presences.inscriptionId,
dateFormationId: presences.dateFormationId,
periode: presences.periode,
heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation,
validateurId: presences.validateurId,
commentaire: presences.commentaire,
signatureUrl: sql<string | null>`${presences}.signatureUrl`,
signatureS3Key: sql<string | null>`${presences}.signatureS3Key`,
apprenantId: apprenants.id,
apprenantNom: apprenants.nom,
apprenantPrenom: apprenants.prenom,
apprenantEmail: apprenants.email,
dateDebut: datesFormation.dateDebut,
dateFin: datesFormation.dateFin,
})
.from(presences)
.innerJoin(inscriptions, eq(presences.inscriptionId, inscriptions.id))
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id))
.where(eq(datesFormation.sequenceId, sequenceId));
return result;
}
/**
* Récupérer les présences pour une inscription spécifique
*/
export async function getPresencesByInscription(inscriptionId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({
presenceId: presences.id,
dateFormationId: presences.dateFormationId,
periode: presences.periode,
heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation,
validateurId: presences.validateurId,
commentaire: presences.commentaire,
signatureUrl: sql<string | null>`${presences}.signatureUrl`,
signatureS3Key: sql<string | null>`${presences}.signatureS3Key`,
dateDebut: datesFormation.dateDebut,
dateFin: datesFormation.dateFin,
})
.from(presences)
.innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id))
.where(eq(presences.inscriptionId, inscriptionId));
return result;
}
/**
* Vérifier si toutes les présences sont validées pour une inscription
*/
export async function checkAllPresencesValidated(inscriptionId: number): Promise<boolean> {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Récupérer l'inscription avec la séquence
const inscription = await db
.select({
sequenceId: inscriptions.sequenceId,
})
.from(inscriptions)
.where(eq(inscriptions.id, inscriptionId))
.limit(1);
if (inscription.length === 0) {
throw new Error("Inscription not found");
}
// Compter le nombre de dates de formation pour cette séquence
const datesCount = await db
.select({ count: datesFormation.id })
.from(datesFormation)
.where(eq(datesFormation.sequenceId, inscription[0].sequenceId));
// Compter le nombre de présences validées pour cette inscription
const presencesCount = await db
.select({ count: presences.id })
.from(presences)
.where(eq(presences.inscriptionId, inscriptionId));
return presencesCount.length === datesCount.length;
}
/**
* Supprimer une présence
*/
export async function supprimerPresence(presenceId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(presences).where(eq(presences.id, presenceId));
return { success: true };
}
/**
* Valider la présence d'un formateur (par QR code ou manuellement)
*/
export async function validerPresenceFormateur(params: {
formateurId: number;
sequenceId: number;
dateFormationId?: number;
periode: "matin" | "apres_midi";
modeValidation: "qrcode" | "manuel";
commentaire?: string;
signatureUrl?: string;
signatureS3Key?: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Récupérer la date de formation
const dateFormation = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.id, params.dateFormationId))
.limit(1);
if (dateFormation.length === 0) {
throw new Error("Date de formation introuvable");
}
// Vérifier que la date actuelle est le jour de la formation ou après
const now = new Date();
const dateDebut = new Date(dateFormation[0].dateDebut);
// Réinitialiser les heures pour comparer uniquement les dates
now.setHours(0, 0, 0, 0);
dateDebut.setHours(0, 0, 0, 0);
if (now < dateDebut) {
throw new Error("Vous ne pouvez pas émarger avant la date de formation");
}
// Vérifier si la présence existe déjà pour cette période
const presenceExistante = await db
.select()
.from(presences)
.where(
and(
eq(presences.formateurId, params.formateurId),
eq(presences.dateFormationId, params.dateFormationId),
sql`${presences.periode} = ${params.periode}`,
sql`${presences.typeUtilisateur} = 'formateur'`
)
);
if (presenceExistante.length > 0) {
throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`);
}
// Créer la présence formateur
await db.insert(presences).values({
typeUtilisateur: "formateur",
formateurId: params.formateurId,
dateFormationId: params.dateFormationId,
periode: params.periode,
heurePresence: new Date(),
modeValidation: params.modeValidation,
commentaire: params.commentaire,
signatureUrl: params.signatureUrl,
signatureS3Key: params.signatureS3Key,
});
return { success: true };
}
/**
* Récupérer les présences du formateur pour une séquence
*/
export async function getPresencesFormateurBySequence(sequenceId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({
presenceId: presences.id,
formateurId: presences.formateurId,
dateFormationId: presences.dateFormationId,
periode: presences.periode,
heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation,
commentaire: presences.commentaire,
signatureUrl: sql<string | null>`${presences}.signatureUrl`,
signatureS3Key: sql<string | null>`${presences}.signatureS3Key`,
})
.from(presences)
.innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id))
.where(
and(
eq(datesFormation.sequenceId, sequenceId),
sql`${presences}.typeUtilisateur = 'formateur'`
)
);
return result;
}

View File

@@ -38,6 +38,35 @@ export async function generateQRCodeDataURL(token: string): Promise<string> {
} }
} }
/**
* Générer un QR code formateur en base64 à partir d'un token
* @param token Le token unique de la séquence
* @returns Une image QR code en base64 (data URL) pour l'émargement formateur
*/
export async function generateQRCodeFormateurDataURL(token: string): Promise<string> {
// URL complète pour scanner le QR code formateur
const parametres = await getParametres();
const url = `${parametres.urlPublique}/emargement-formateur/${token}`;
try {
const qrCodeDataURL = await QRCode.toDataURL(url, {
errorCorrectionLevel: "H",
type: "image/png",
width: 400,
margin: 2,
color: {
dark: "#000000",
light: "#FFFFFF",
},
});
return qrCodeDataURL;
} catch (error) {
console.error("Erreur lors de la génération du QR code formateur:", error);
throw new Error("Impossible de générer le QR code formateur");
}
}
/** /**
* Générer un QR code en buffer PNG * Générer un QR code en buffer PNG
* @param token Le token unique de la séquence * @param token Le token unique de la séquence

View File

@@ -2517,6 +2517,42 @@ export const appRouter = router({
}; };
}), }),
// Générer un QR code formateur pour une séquence
generateQRCodeFormateur: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { generateQRToken, generateQRCodeFormateurDataURL } = await import("./qrCodeGenerator");
const { getDb } = await import("./db");
const { sequences } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier que la séquence existe
const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1);
if (sequence.length === 0) {
throw new Error("Séquence introuvable");
}
// Générer un nouveau token si nécessaire
let token = sequence[0].qrCodeToken;
if (!token) {
token = generateQRToken();
await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId));
}
// Générer le QR code formateur
const qrCodeDataURL = await generateQRCodeFormateurDataURL(token);
return {
token,
qrCodeDataURL,
};
}),
// Récupérer les informations de la séquence par token (pour formateurs) // Récupérer les informations de la séquence par token (pour formateurs)
getSequenceByToken: publicProcedure getSequenceByToken: publicProcedure
.input(z.object({ .input(z.object({
@@ -2547,15 +2583,86 @@ export const appRouter = router({
}; };
}), }),
// Valider la présence d'un formateur (QR code dédié)
validerFormateur: publicProcedure
.input(z.object({
token: z.string(),
email: z.string().email(),
periode: z.enum(["matin", "apres_midi"]),
signatureDataUrl: z.string(),
}))
.mutation(async ({ input }) => {
const { getDb } = await import("./db");
const { formateurs, sequences, datesFormation } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const { validerPresenceFormateur } = await import("./presenceDb");
const { storagePut } = await import("./storage");
const crypto = await import("crypto");
const path = await import("path");
const fs = await import("fs/promises");
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier que l'email correspond à un formateur
const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1);
if (formateurData.length === 0) {
throw new Error("Aucun formateur trouvé avec cet email");
}
// Vérifier le token et récupérer la séquence
const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1);
if (sequence.length === 0) {
throw new Error("QR code invalide");
}
// Récupérer la première date de la séquence
const dates = await db.select().from(datesFormation).where(eq(datesFormation.sequenceId, sequence[0].id)).orderBy(datesFormation.dateDebut).limit(1);
if (dates.length === 0) {
throw new Error("Aucune date de formation trouvée pour cette séquence");
}
// Stocker la signature localement
const formationId = sequence[0].formationId;
const sequenceId = sequence[0].id;
const uploadsDir = path.join(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`);
await fs.mkdir(uploadsDir, { recursive: true });
const randomSuffix = crypto.randomBytes(8).toString("hex");
const fileName = `signature-formateur-${formateurData[0].id}-${dates[0].id}-${randomSuffix}.png`;
const filePath = path.join(uploadsDir, fileName);
const base64Data = input.signatureDataUrl.split(",")[1];
const buffer = Buffer.from(base64Data, "base64");
await fs.writeFile(filePath, buffer);
const signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`;
const signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`;
// Valider la présence formateur
await validerPresenceFormateur({
formateurId: formateurData[0].id,
sequenceId: sequence[0].id,
dateFormationId: dates[0].id,
periode: input.periode,
modeValidation: "qrcode",
signatureUrl,
signatureS3Key,
});
return { success: true };
}),
// Valider une présence (scan QR code ou manuel) // Valider une présence (scan QR code ou manuel)
valider: publicProcedure valider: publicProcedure
.input(z.object({ .input(z.object({
token: z.string().optional(), token: z.string().optional(),
email: z.string().email(), email: z.string().email(),
inscriptionId: z.number().optional(), inscriptionId: z.number().optional(),
dateFormationId: z.number(), dateFormationId: z.number().optional(),
dateId: z.number().optional(), // Alias pour dateFormationId
periode: z.enum(["matin", "apres_midi"]), periode: z.enum(["matin", "apres_midi"]),
modeValidation: z.enum(["qrcode", "manuel"]), modeValidation: z.enum(["qrcode", "manuel"]).optional().default("qrcode"),
validateurId: z.number().optional(), validateurId: z.number().optional(),
commentaire: z.string().optional(), commentaire: z.string().optional(),
signatureDataUrl: z.string().optional(), signatureDataUrl: z.string().optional(),
@@ -2575,14 +2682,24 @@ export const appRouter = router({
// Si mode QR code, vérifier le token // Si mode QR code, vérifier le token
let sequenceId: number | undefined; let sequenceId: number | undefined;
let autoDateFormationId: number | undefined;
if (input.modeValidation === "qrcode" && input.token) { if (input.modeValidation === "qrcode" && input.token) {
// Vérifier que le token correspond à une séquence // Vérifier que le token correspond à une séquence
const { datesFormation } = await import("../drizzle/schema");
const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1); const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1);
if (sequence.length === 0) { if (sequence.length === 0) {
throw new Error("QR code invalide"); throw new Error("QR code invalide");
} }
sequenceId = sequence[0].id; sequenceId = sequence[0].id;
// Si formateur, récupérer automatiquement la première date de la séquence
if (isFormateur && !input.dateFormationId && !input.dateId) {
const dates = await db.select().from(datesFormation).where(eq(datesFormation.sequenceId, sequenceId)).orderBy(datesFormation.dateDebut).limit(1);
if (dates.length > 0) {
autoDateFormationId = dates[0].id;
}
}
// Si apprenant, vérifier l'inscription // Si apprenant, vérifier l'inscription
if (!isFormateur && input.inscriptionId) { if (!isFormateur && input.inscriptionId) {
const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
@@ -2631,7 +2748,8 @@ export const appRouter = router({
const randomSuffix = crypto.randomBytes(8).toString("hex"); const randomSuffix = crypto.randomBytes(8).toString("hex");
const userType = isFormateur ? "formateur" : "apprenant"; const userType = isFormateur ? "formateur" : "apprenant";
const userId = isFormateur ? formateurData[0].id : input.inscriptionId; const userId = isFormateur ? formateurData[0].id : input.inscriptionId;
const fileName = `signature-${userType}-${userId}-${input.dateFormationId}-${randomSuffix}.png`; const dateRef = input.dateFormationId || input.dateId || "no-date";
const fileName = `signature-${userType}-${userId}-${dateRef}-${randomSuffix}.png`;
const filePath = path.join(uploadsDir, fileName); const filePath = path.join(uploadsDir, fileName);
// Convertir le data URL en buffer et sauvegarder // Convertir le data URL en buffer et sauvegarder
@@ -2651,9 +2769,9 @@ export const appRouter = router({
result = await validerPresenceFormateur({ result = await validerPresenceFormateur({
formateurId: formateurData[0].id, formateurId: formateurData[0].id,
sequenceId: finalSequenceId, sequenceId: finalSequenceId,
dateFormationId: input.dateFormationId, dateFormationId: input.dateFormationId || input.dateId || undefined,
periode: input.periode, periode: input.periode,
modeValidation: input.modeValidation, modeValidation: input.modeValidation || "qrcode",
commentaire: input.commentaire, commentaire: input.commentaire,
signatureUrl, signatureUrl,
signatureS3Key, signatureS3Key,
@@ -2661,11 +2779,13 @@ export const appRouter = router({
} else { } else {
// Valider la présence de l'apprenant // Valider la présence de l'apprenant
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant"); if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
const dateFormId = input.dateFormationId || input.dateId;
if (!dateFormId) throw new Error("Date de formation requise pour un apprenant");
result = await validerPresence({ result = await validerPresence({
inscriptionId: input.inscriptionId, inscriptionId: input.inscriptionId,
dateFormationId: input.dateFormationId, dateFormationId: dateFormId,
periode: input.periode, periode: input.periode,
modeValidation: input.modeValidation, modeValidation: input.modeValidation || "qrcode",
validateurId: input.validateurId, validateurId: input.validateurId,
commentaire: input.commentaire, commentaire: input.commentaire,
signatureUrl, signatureUrl,

31
todo.md
View File

@@ -1523,3 +1523,34 @@
- [x] Corriger le bug de validation du formulaire email (ajout détection formateur/apprenant) - [x] Corriger le bug de validation du formulaire email (ajout détection formateur/apprenant)
- [ ] Tester sur smartphone - [ ] Tester sur smartphone
- [x] Déployer la correction sur le VPS - [x] Déployer la correction sur le VPS
## Bug : Chargement infini en mode formateur (émargement QR code)
- [x] Diagnostiquer pourquoi getSequenceByToken ne retourne pas de données
- [x] Vérifier que le token QR code est bien passé dans l'URL
- [x] Corriger le problème de récupération des données de séquence
- [ ] Tester sur smartphone
- [x] Déployer la correction sur le VPS (redémarrage PM2 complet effectué)
## Correction finale : Simplifier l'approche sans getSequenceByToken
- [x] Retirer getSequenceByToken et utiliser la logique existante de validation
- [x] Modifier EmargementScan.tsx pour détecter le formateur sans appel API supplémentaire
- [ ] Vider tous les caches (Nginx, navigateur)
- [ ] Tester le flux complet
- [x] Déployer sur le VPS
## Bug : Erreur de validation backend pour les formateurs
- [ ] Modifier le schéma de validation de `presences.valider` pour rendre `inscriptionId` et `dateFormationId` optionnels
- [ ] Adapter la logique backend pour gérer les formateurs sans ces champs
- [ ] Déployer la correction sur le VPS
## Solution finale : QR code dédié pour les formateurs
- [x] Créer la page `/emargement-formateur/:token` avec formulaire simplifié (email + période + signature)
- [x] Créer la procédure backend `presences.validerFormateur` dédiée
- [x] Ajouter un bouton "QR Code Formateur" dans FormateurEmargement.tsx
- [x] Créer la procédure `generateQRCodeFormateur` pour générer l'URL formateur
- [ ] Tester le flux complet
- [x] Déployer sur le VPS